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)
}
}