Vectrum
Sign In
289 lines · 9.9 KB · Go
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)
	}
}