package websearch import ( "context" "os" "path/filepath" "strconv" "strings" "testing" ) // fakeSearch writes a shell script that prints body on stdout and exits with // code, and returns a command template pointing at it. Nothing in this package // may depend on ddgs actually being installed: the tests have to pass on a // machine that has never heard of it. func fakeSearch(t *testing.T, body string, code int) string { t.Helper() path := filepath.Join(t.TempDir(), "fakesearch") script := "#!/bin/sh\ncat <<'JSONEOF'\n" + body + "\nJSONEOF\nexit " + strconv.Itoa(code) + "\n" if err := os.WriteFile(path, []byte(script), 0o755); err != nil { t.Fatal(err) } return path + " {{query}} {{count}}" } func search(t *testing.T, template, query string, count int) ([]Result, error) { t.Helper() s, err := NewCommand(template) if err != nil { t.Fatalf("NewCommand(%q): %v", template, err) } return s.Search(context.Background(), query, count) } // The three shapes a search tool might print, all of which have to work: ddgs // has changed its own between versions, and "upgrade ddgs and search breaks" // is not a failure mode worth shipping. func TestCommandParsesEveryOutputShape(t *testing.T) { cases := []struct { name string body string }{{ name: "json array", body: `[{"title":"Go 1.26","href":"https://go.dev/doc/go1.26","body":"release notes"}]`, }, { name: "wrapper object", body: `{"results":[{"title":"Go 1.26","url":"https://go.dev/doc/go1.26","snippet":"release notes"}]}`, }, { name: "newline delimited", body: `{"title":"Go 1.26","link":"https://go.dev/doc/go1.26","description":"release notes"}`, }, { name: "mixed case keys", body: `[{"Title":"Go 1.26","HREF":"https://go.dev/doc/go1.26","Body":"release notes"}]`, }} for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, err := search(t, fakeSearch(t, tc.body, 0), "go 1.26", 5) if err != nil { t.Fatal(err) } if len(got) != 1 { t.Fatalf("got %d results, want 1: %+v", len(got), got) } want := Result{Title: "Go 1.26", URL: "https://go.dev/doc/go1.26", Snippet: "release notes"} if got[0] != want { t.Errorf("got %+v, want %+v", got[0], want) } }) } } // The query is written by the model, so the one thing this must never do is // let it become a command. argv goes to execve as it stands; there is no shell // to interpret a semicolon, a backtick or $(…). func TestCommandNeverReachesAShell(t *testing.T) { dir := t.TempDir() argsFile := filepath.Join(dir, "args") canary := filepath.Join(dir, "pwned") script := filepath.Join(dir, "fakesearch") body := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + argsFile + "\necho '[]'\n" if err := os.WriteFile(script, []byte(body), 0o755); err != nil { t.Fatal(err) } nasty := "go 1.26; touch " + canary + " `touch " + canary + "` $(touch " + canary + ")" if _, err := search(t, script+" {{query}} {{count}}", nasty, 5); err != nil { t.Fatal(err) } if _, err := os.Stat(canary); err == nil { t.Fatal("the query was interpreted by a shell — it created a file") } raw, err := os.ReadFile(argsFile) if err != nil { t.Fatal(err) } args := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") if len(args) != 2 { t.Fatalf("the command got %d arguments, want 2 (the query must be one): %q", len(args), args) } if args[0] != nasty { t.Errorf("the query arrived as %q, want it verbatim", args[0]) } if args[1] != "5" { t.Errorf("the count arrived as %q, want 5", args[1]) } } // What the command actually printed is the only clue anyone has when a search // goes wrong, so it has to be in the error rather than in a log nobody reads. func TestCommandErrorsCarryTheOutput(t *testing.T) { _, err := search(t, fakeSearch(t, "Traceback (most recent call last): boom", 0), "go", 5) if err == nil { t.Fatal("output that is not JSON was accepted") } if !strings.Contains(err.Error(), "Traceback") { t.Errorf("the error does not say what the command printed: %v", err) } _, err = search(t, fakeSearch(t, "rate limited", 2), "go", 5) if err == nil { t.Fatal("a non-zero exit was accepted") } if !strings.Contains(err.Error(), "rate limited") { t.Errorf("the error does not say what the command printed: %v", err) } } // A missing binary is the most likely failure of all, and it must name the fix // rather than an errno. func TestMissingBinaryIsExplained(t *testing.T) { if err := Available("ddgs-that-is-not-installed {{query}}"); err == nil { t.Fatal("a missing binary was reported as available") } else if !strings.Contains(err.Error(), "not on PATH") { t.Errorf("unhelpful error: %v", err) } if err := Available(DefaultCommand); err != nil { if !strings.Contains(err.Error(), "pipx install ddgs") { t.Errorf("the default's error does not say how to install it: %v", err) } } _, err := search(t, "ddgs-that-is-not-installed {{query}}", "go", 5) if err == nil { t.Fatal("searching with a missing binary succeeded") } if !strings.Contains(err.Error(), "not installed") { t.Errorf("unhelpful error: %v", err) } } // A template is read at startup, where the user can see the complaint. A // template with no {{query}} would otherwise search for the same thing forever // and look like a broken provider. func TestTemplateIsValidatedUpFront(t *testing.T) { for _, tc := range []struct{ name, template string }{ {"no query placeholder", "ddgs text -o json"}, {"unbalanced quote", `ddgs text -q "{{query}}`}, {"empty after trimming", " "}, } { t.Run(tc.name, func(t *testing.T) { if tc.template == " " { // An empty template is the default, which is valid: the point // of this case is that it does not become an empty argv. s, err := NewCommand(tc.template) if err != nil { t.Fatalf("an empty template should mean the default: %v", err) } if s.Name() != "ddgs" { t.Errorf("the default is %q, want ddgs", s.Name()) } return } if _, err := NewCommand(tc.template); err == nil { t.Errorf("%q was accepted", tc.template) } }) } } // Quoted arguments are the one shell-ism a command line in a config file needs. func TestSplitArgsHandlesQuotes(t *testing.T) { got, err := splitArgs(`ddgs text -q "{{query}} site:go.dev" -m '5'`) if err != nil { t.Fatal(err) } want := []string{"ddgs", "text", "-q", "{{query}} site:go.dev", "-m", "5"} if len(got) != len(want) { t.Fatalf("got %q, want %q", got, want) } for i := range want { if got[i] != want[i] { t.Errorf("argument %d = %q, want %q", i, got[i], want[i]) } } } // More results than were asked for is a provider ignoring its own flag; the // budget belongs to the caller, so the extra are dropped here. func TestCountIsEnforcedOnTheResults(t *testing.T) { body := `[{"title":"a","href":"https://a.example"}, {"title":"b","href":"https://b.example"}, {"title":"c","href":"https://c.example"}]` got, err := search(t, fakeSearch(t, body, 0), "go", 2) if err != nil { t.Fatal(err) } if len(got) != 2 { t.Errorf("got %d results, want 2", len(got)) } } // A row with no URL is not a search result — it is something else the tool // happened to print — and a row with no title is still usable. func TestRowsWithoutAUsableURLAreDropped(t *testing.T) { body := `[{"title":"no url here"},{"href":"https://b.example"}]` got, err := search(t, fakeSearch(t, body, 0), "go", 5) if err != nil { t.Fatal(err) } if len(got) != 1 { t.Fatalf("got %d results, want 1: %+v", len(got), got) } if got[0].Title != "https://b.example" { t.Errorf("a result with no title should fall back to its URL, got %q", got[0].Title) } } // The format the shipped default actually produces. `-o json` sounds like the // obvious flag and is a trap: it writes a file and prints nothing, so the // default asks for the plain listing and this is what has to be read. The // sample is real ddgs output, wrapped exactly as it wraps it. func TestCommandParsesTheDdgsListing(t *testing.T) { const listing = `1. title errors package - errors - Go Packages href https://pkg.go.dev/errors body package main import ( "errors" "fmt" ) func main() { err1 := errors.New("err1") err2 := errors.New("err2") err := errors.Join(err1, err2) 2. title errors: Using ` + "`errors.Join`" + ` on a joinError should expand the existing joined error · Issue #60209 · golang/go href https://github.com/golang/go/issues/60209 body May 15, 2023 - After err = errors.Join(err, errors.New("err2")) ` got, err := search(t, fakeSearch(t, listing, 0), "errors.Join", 5) if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("got %d results, want 2: %+v", len(got), got) } if got[0].Title != "errors package - errors - Go Packages" { t.Errorf("title = %q", got[0].Title) } if got[0].URL != "https://pkg.go.dev/errors" { t.Errorf("url = %q", got[0].URL) } // The wrapped continuation lines have to be folded back into one snippet, // or every result ends mid-sentence. if !strings.HasSuffix(got[0].Snippet, "errors.Join(err1, err2)") { t.Errorf("the wrapped body was not rejoined: %q", got[0].Snippet) } // A wrapped title continues too, and the record after it must still start // cleanly rather than inheriting the one before. if !strings.Contains(got[1].Title, "Issue #60209") { t.Errorf("the wrapped title was not rejoined: %q", got[1].Title) } if got[1].URL != "https://github.com/golang/go/issues/60209" { t.Errorf("url = %q", got[1].URL) } } // Output that is neither JSON nor a listing must still fail loudly, rather // than being read as an empty result set — "no results" and "your search tool // is broken" are different things and the model acts differently on each. func TestCommandRejectsUnrecognisedOutput(t *testing.T) { _, err := search(t, fakeSearch(t, "usage: ddgs [OPTIONS] COMMAND", 0), "go", 5) if err == nil { t.Fatal("a usage message was accepted as results") } if !strings.Contains(err.Error(), "usage") { t.Errorf("the error does not show what was printed: %v", err) } }