main 4509ec7

web_search_test.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"io"
8	"net"
9	"net/http"
10	"net/http/httptest"
11	"net/url"
12	"strings"
13	"sync/atomic"
14	"testing"
15	"time"
16
17	"github.com/bnema/zut/packages/provider"
18)
19
20type webSearchRoundTripper func(*http.Request) (*http.Response, error)
21
22func (f webSearchRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
23	return f(r)
24}
25
26func webSearchDirectTestTransport(t *testing.T) http.RoundTripper {
27	t.Helper()
28	transport := &http.Transport{Proxy: nil}
29	t.Cleanup(transport.CloseIdleConnections)
30	return transport
31}
32
33func webSearchText(t *testing.T, resultText any) string {
34	t.Helper()
35	result, ok := resultText.(provider.TextBlock)
36	if !ok {
37		t.Fatalf("content block = %T, want provider.TextBlock", resultText)
38	}
39	return result.Text
40}
41
42func executeWebSearch(t *testing.T, tool *WebSearchTool, ctx context.Context, args any) (string, bool, any) {
43	t.Helper()
44	raw, err := json.Marshal(args)
45	if err != nil {
46		t.Fatal(err)
47	}
48	return executeWebSearchRaw(t, tool, ctx, raw)
49}
50
51func executeWebSearchRaw(t *testing.T, tool *WebSearchTool, ctx context.Context, raw json.RawMessage) (string, bool, any) {
52	t.Helper()
53	result, err := tool.Execute(ctx, raw, nil)
54	if err != nil {
55		t.Fatal(err)
56	}
57	if len(result.Content) != 1 {
58		t.Fatalf("content = %#v", result.Content)
59	}
60	return webSearchText(t, result.Content[0]), result.IsError, result.Details
61}
62
63func webSearchHTMLResponse(request *http.Request, body string) *http.Response {
64	return &http.Response{
65		StatusCode: http.StatusOK,
66		Status:     "200 OK",
67		Header:     http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
68		Body:       io.NopCloser(strings.NewReader(body)),
69		Request:    request,
70	}
71}
72
73const webSearchFixture = `<!doctype html><html><body>
74<div class="result"><a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Fone">One <b>result</b></a><a class="result__snippet">First <em>snippet</em>.</a></div>
75<div class="result"><h2><a href="https://EXAMPLE.com/one">Duplicate</a></h2><span class="result__snippet">Ignored duplicate.</span></div>
76<div class="result"><a class="result__a" href="https://example.org/two#fragment">Two</a><div class="result__snippet">Second snippet.</div></div>
77</body></html>`
78
79func TestWebSearchDescriptionAvoidsSearchOperators(t *testing.T) {
80	description := NewWebSearchTool().Description()
81	for _, want := range []string{"ordinary keyword queries", "site:"} {
82		if !strings.Contains(description, want) {
83			t.Errorf("description = %q, want %q", description, want)
84		}
85	}
86}
87
88func TestWebSearchRequestAndResultContract(t *testing.T) {
89	var calls atomic.Int64
90	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
91		calls.Add(1)
92		if r.Method != http.MethodGet {
93			t.Errorf("method = %s, want GET", r.Method)
94		}
95		if r.URL.Path != "/html/" {
96			t.Errorf("path = %q, want /html/", r.URL.Path)
97		}
98		if got, want := r.URL.RawQuery, "q=Go+JSON-RPC+%E2%9C%93"; got != want {
99			t.Errorf("query = %q, want %q", got, want)
100		}
101		if got := r.Header.Get("User-Agent"); got != webSearchUserAgent {
102			t.Errorf("User-Agent = %q", got)
103		}
104		if got := r.Header.Get("Cookie"); got != "" {
105			t.Errorf("Cookie = %q, want none", got)
106		}
107		w.Header().Set("Set-Cookie", "session=should-not-be-reused")
108		w.Header().Set("Content-Type", "text/html; charset=utf-8")
109		_, _ = w.Write([]byte(webSearchFixture))
110	}))
111	defer server.Close()
112
113	tool := &WebSearchTool{transport: webSearchDirectTestTransport(t), endpointOverride: server.URL + "/html/?ignored=value"}
114	text, isError, details := executeWebSearch(t, tool, context.Background(), map[string]any{
115		"query":       "  Go JSON-RPC ✓  ",
116		"max_results": 2,
117	})
118	if isError {
119		t.Fatalf("unexpected error: %s", text)
120	}
121	if got := calls.Load(); got != 1 {
122		t.Fatalf("calls = %d, want 1", got)
123	}
124	if !strings.HasPrefix(text, webSearchDisclosure+"\n\n[1] One result\n    https://example.com/one\n    First snippet.") {
125		t.Fatalf("unexpected result text:\n%s", text)
126	}
127	if strings.Contains(text, "#fragment") || !strings.Contains(text, "https://example.org/two") {
128		t.Fatalf("result URLs were not normalized:\n%s", text)
129	}
130	metadata, ok := details.(map[string]any)
131	if !ok || metadata["backend"] != "DuckDuckGo HTML" || metadata["query"] != "Go JSON-RPC ✓" {
132		t.Fatalf("details = %#v", details)
133	}
134	if count, ok := metadata["result_count"].(int); !ok || count != 2 {
135		t.Fatalf("result_count = %#v", metadata["result_count"])
136	}
137}
138
139func TestWebSearchEndpointIsFixed(t *testing.T) {
140	var gotURL string
141	tool := NewWebSearchTool()
142	tool.transport = webSearchRoundTripper(func(request *http.Request) (*http.Response, error) {
143		gotURL = request.URL.String()
144		return webSearchHTMLResponse(request, webSearchFixture), nil
145	})
146	text, isError, _ := executeWebSearch(t, tool, context.Background(), map[string]any{"query": "fixed"})
147	if isError {
148		t.Fatalf("unexpected error: %s", text)
149	}
150	if gotURL != webSearchEndpoint+"?q=fixed" {
151		t.Fatalf("request URL = %q, want %q", gotURL, webSearchEndpoint+"?q=fixed")
152	}
153}
154
155func TestWebSearchUsesConfiguredProxyTransport(t *testing.T) {
156	var proxyCalls atomic.Int64
157	proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
158		proxyCalls.Add(1)
159		if r.URL.Scheme != "http" || r.URL.Host != "web-search.invalid" {
160			t.Errorf("proxy request URL = %q, want absolute web-search.invalid URL", r.URL)
161		}
162		if got, want := r.URL.Path, "/html/"; got != want {
163			t.Errorf("proxy request path = %q, want %q", got, want)
164		}
165		if got, want := r.URL.RawQuery, "q=environment"; got != want {
166			t.Errorf("proxy request query = %q, want %q", got, want)
167		}
168		w.Header().Set("Content-Type", "text/html")
169		_, _ = w.Write([]byte(webSearchFixture))
170	}))
171	defer proxy.Close()
172
173	proxyURL, err := url.Parse(proxy.URL)
174	if err != nil {
175		t.Fatal(err)
176	}
177	transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
178	t.Cleanup(transport.CloseIdleConnections)
179	tool := &WebSearchTool{
180		endpointOverride: "http://web-search.invalid/html/",
181		transport:        transport,
182	}
183	text, isError, _ := executeWebSearch(t, tool, context.Background(), map[string]any{"query": "environment"})
184	if isError {
185		t.Fatalf("unexpected error through configured proxy: %s", text)
186	}
187	if got := proxyCalls.Load(); got != 1 {
188		t.Fatalf("proxy calls = %d, want 1", got)
189	}
190}
191
192func TestWebSearchExecuteClosesOwnedTransportConnections(t *testing.T) {
193	const calls = 3
194	closed := make(chan struct{}, calls)
195	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
196		w.Header().Set("Content-Type", "text/html")
197		_, _ = w.Write([]byte(webSearchFixture))
198	}))
199	server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
200		if state == http.StateClosed {
201			closed <- struct{}{}
202		}
203	}
204	server.Start()
205	defer server.Close()
206
207	// ProxyFromEnvironment always bypasses localhost. This keeps this regression
208	// test local even after the proxy contract test initializes its cached proxy.
209	endpoint := strings.Replace(server.URL, "127.0.0.1", "localhost", 1)
210	tool := &WebSearchTool{endpointOverride: endpoint}
211	for i := 0; i < calls; i++ {
212		text, isError, _ := executeWebSearch(t, tool, context.Background(), map[string]any{"query": "lifecycle"})
213		if isError {
214			t.Fatalf("call %d returned an error: %s", i+1, text)
215		}
216	}
217	for i := 0; i < calls; i++ {
218		select {
219		case <-closed:
220		case <-time.After(time.Second):
221			t.Fatalf("call %d left its transport connection open", i+1)
222		}
223	}
224}
225
226func TestWebSearchSanitizesResultsAndDetails(t *testing.T) {
227	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
228		w.Header().Set("Content-Type", "text/html")
229		_, _ = w.Write([]byte("<div class=\"result\"><a class=\"result__a\" href=\"https://example.com\">\x1b[31mRed\x1b[0m\x00 title</a><span class=\"result__snippet\">\x1b]0;terminal title\x07Clean\x01 snippet</span></div>"))
230	}))
231	defer server.Close()
232
233	tool := &WebSearchTool{transport: webSearchDirectTestTransport(t), endpointOverride: server.URL}
234	text, isError, details := executeWebSearch(t, tool, context.Background(), map[string]any{"query": "safe\x1b[31m query\x00"})
235	if isError {
236		t.Fatalf("unexpected error: %s", text)
237	}
238	if want := webSearchDisclosure + "\n\n[1] Red title\n    https://example.com\n    Clean snippet\n"; text != want {
239		t.Fatalf("sanitized text = %q, want %q", text, want)
240	}
241	metadata, ok := details.(map[string]any)
242	if !ok {
243		t.Fatalf("details = %#v", details)
244	}
245	if metadata["query"] != "safe query" {
246		t.Fatalf("sanitized query details = %#v", metadata["query"])
247	}
248	results, ok := metadata["results"].([]webSearchResult)
249	if !ok || len(results) != 1 {
250		t.Fatalf("sanitized result details = %#v", metadata["results"])
251	}
252	if results[0].Title != "Red title" || results[0].Snippet != "Clean snippet" {
253		t.Fatalf("sanitized result = %#v", results[0])
254	}
255	if strings.ContainsAny(text+results[0].Title+results[0].Snippet+metadata["query"].(string), "\x00\x01\x1b") {
256		t.Fatalf("control character remained in model-visible output")
257	}
258}
259
260func TestWebSearchRejectsInvalidArgumentsWithoutRequest(t *testing.T) {
261	tool := &WebSearchTool{transport: webSearchRoundTripper(func(*http.Request) (*http.Response, error) {
262		t.Fatal("unexpected request")
263		return nil, nil
264	})}
265	cases := []struct {
266		name string
267		args any
268		want string
269	}{
270		{name: "missing query", args: map[string]any{}, want: "web search: invalid query"},
271		{name: "empty query", args: map[string]any{"query": " \t "}, want: "web search: invalid query"},
272		{name: "long query", args: map[string]any{"query": strings.Repeat("界", webSearchMaxQueryRunes+1)}, want: "web search: invalid query"},
273		{name: "zero results", args: map[string]any{"query": "x", "max_results": 0}, want: "web search: invalid max_results"},
274		{name: "too many results", args: map[string]any{"query": "x", "max_results": 11}, want: "web search: invalid max_results"},
275		{name: "noninteger results", args: map[string]any{"query": "x", "max_results": "2"}, want: "web search: invalid max_results"},
276		{name: "unknown key", args: map[string]any{"query": "x", "unexpected": true}, want: "web search: invalid query"},
277	}
278
279	for _, tc := range cases {
280		t.Run(tc.name, func(t *testing.T) {
281			text, isError, _ := executeWebSearch(t, tool, context.Background(), tc.args)
282			if !isError || text != tc.want {
283				t.Fatalf("result = (%q, %v), want (%q, true)", text, isError, tc.want)
284			}
285		})
286	}
287
288	largeRaw := json.RawMessage(strings.Repeat(" ", webSearchMaxInputBytes) + `{"query":"x"}`)
289	text, isError, _ := executeWebSearchRaw(t, tool, context.Background(), largeRaw)
290	if !isError || text != "web search: invalid query" {
291		t.Fatalf("large raw input result = (%q, %v)", text, isError)
292	}
293}
294
295func TestWebSearchBackendFailuresAreSanitized(t *testing.T) {
296	cases := []struct {
297		name    string
298		status  int
299		content string
300		body    string
301		want    string
302	}{
303		{name: "rate limited", status: http.StatusTooManyRequests, content: "text/html", body: "private backend diagnostics", want: "web search: DuckDuckGo returned an unavailable response"},
304		{name: "wrong content type", status: http.StatusOK, content: "application/json", body: `{}`, want: "web search: DuckDuckGo returned an unavailable response"},
305		{name: "challenge", status: http.StatusOK, content: "text/html", body: "<html>unusual traffic captcha</html>", want: "web search: DuckDuckGo returned an unavailable response"},
306		{name: "unparseable", status: http.StatusOK, content: "text/html", body: "<html><body>ordinary page</body></html>", want: "web search: DuckDuckGo returned an unparseable response"},
307		{name: "no usable", status: http.StatusOK, content: "text/html", body: `<div class="result"><a class="result__a" href="javascript:alert(1)">bad</a></div>`, want: "web search: no usable HTTP(S) results"},
308		{name: "malformed redirect", status: http.StatusOK, content: "text/html", body: `<div class="result"><a class="result__a" href="/l/?uddg=%">bad</a></div>`, want: "web search: no usable HTTP(S) results"},
309	}
310	for _, tc := range cases {
311		t.Run(tc.name, func(t *testing.T) {
312			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
313				w.Header().Set("Content-Type", tc.content)
314				w.WriteHeader(tc.status)
315				_, _ = w.Write([]byte(tc.body))
316			}))
317			defer server.Close()
318			tool := &WebSearchTool{transport: webSearchDirectTestTransport(t), endpointOverride: server.URL}
319			text, isError, _ := executeWebSearch(t, tool, context.Background(), map[string]any{"query": "test"})
320			if !isError || text != tc.want {
321				t.Fatalf("result = (%q, %v), want (%q, true)", text, isError, tc.want)
322			}
323		})
324	}
325}
326
327func TestWebSearchDoesNotUseCookies(t *testing.T) {
328	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
329		if got := r.Header.Get("Cookie"); got != "" {
330			t.Errorf("Cookie = %q, want none", got)
331		}
332		w.Header().Set("Set-Cookie", "session=private")
333		w.Header().Set("Content-Type", "text/html")
334		_, _ = w.Write([]byte(webSearchFixture))
335	}))
336	defer server.Close()
337
338	tool := &WebSearchTool{transport: webSearchDirectTestTransport(t), endpointOverride: server.URL}
339	for i := 0; i < 2; i++ {
340		text, isError, _ := executeWebSearch(t, tool, context.Background(), map[string]any{"query": "cookie"})
341		if isError {
342			t.Fatalf("unexpected error: %s", text)
343		}
344	}
345	if client := tool.client(); client.Jar != nil {
346		t.Fatalf("client jar = %v, want nil", client.Jar)
347	}
348}
349
350func TestWebSearchRejectsRedirectAndOversizedResponse(t *testing.T) {
351	var followed atomic.Bool
352	redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
353		if r.URL.Path == "/next" {
354			followed.Store(true)
355		}
356		http.Redirect(w, r, "/next", http.StatusFound)
357	}))
358	defer redirect.Close()
359	tool := &WebSearchTool{transport: webSearchDirectTestTransport(t), endpointOverride: redirect.URL}
360	text, isError, _ := executeWebSearch(t, tool, context.Background(), map[string]any{"query": "test"})
361	if !isError || text != "web search: DuckDuckGo request failed" {
362		t.Fatalf("redirect result = (%q, %v)", text, isError)
363	}
364	if followed.Load() {
365		t.Fatal("redirect was followed")
366	}
367
368	oversized := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
369		w.Header().Set("Content-Type", "text/html")
370		_, _ = w.Write([]byte(strings.Repeat("x", webSearchMaxBodyBytes+1)))
371	}))
372	defer oversized.Close()
373	tool = &WebSearchTool{transport: webSearchDirectTestTransport(t), endpointOverride: oversized.URL}
374	text, isError, _ = executeWebSearch(t, tool, context.Background(), map[string]any{"query": "test"})
375	if !isError || text != "web search: DuckDuckGo returned an unavailable response" {
376		t.Fatalf("oversized result = (%q, %v)", text, isError)
377	}
378}
379
380func TestWebSearchCancellationAndTransportFailure(t *testing.T) {
381	cancelled, cancel := context.WithCancel(context.Background())
382	cancel()
383	tool := &WebSearchTool{}
384	text, isError, _ := executeWebSearch(t, tool, cancelled, map[string]any{"query": "test"})
385	if !isError || text != "web search: request cancelled" {
386		t.Fatalf("cancelled result = (%q, %v)", text, isError)
387	}
388
389	deadline, deadlineCancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
390	defer deadlineCancel()
391	text, isError, _ = executeWebSearch(t, tool, deadline, map[string]any{"query": "test"})
392	if !isError || text != "web search: timed out" {
393		t.Fatalf("deadline result = (%q, %v)", text, isError)
394	}
395
396	tool = &WebSearchTool{transport: webSearchRoundTripper(func(*http.Request) (*http.Response, error) {
397		return nil, errors.New("dial private proxy failed")
398	})}
399	text, isError, _ = executeWebSearch(t, tool, context.Background(), map[string]any{"query": "test"})
400	if !isError || text != "web search: DuckDuckGo request failed" || strings.Contains(text, "proxy") {
401		t.Fatalf("transport result = (%q, %v)", text, isError)
402	}
403}
404
405func TestWebSearchActiveCancellationAndRequestDeadline(t *testing.T) {
406	type asyncResult struct {
407		text    string
408		isError bool
409	}
410	call := func(tool *WebSearchTool, ctx context.Context) <-chan asyncResult {
411		done := make(chan asyncResult, 1)
412		go func() {
413			result, err := tool.Execute(ctx, json.RawMessage(`{"query":"active"}`), nil)
414			if err != nil || len(result.Content) != 1 {
415				done <- asyncResult{text: "unexpected tool error", isError: true}
416				return
417			}
418			block, ok := result.Content[0].(provider.TextBlock)
419			if !ok {
420				done <- asyncResult{text: "unexpected content", isError: true}
421				return
422			}
423			done <- asyncResult{text: block.Text, isError: result.IsError}
424		}()
425		return done
426	}
427	wait := func(t *testing.T, done <-chan asyncResult) asyncResult {
428		t.Helper()
429		select {
430		case result := <-done:
431			return result
432		case <-time.After(time.Second):
433			t.Fatal("web search did not finish after context termination")
434			return asyncResult{}
435		}
436	}
437
438	started := make(chan struct{})
439	tool := &WebSearchTool{transport: webSearchRoundTripper(func(request *http.Request) (*http.Response, error) {
440		close(started)
441		<-request.Context().Done()
442		return nil, request.Context().Err()
443	})}
444	ctx, cancel := context.WithCancel(context.Background())
445	done := call(tool, ctx)
446	select {
447	case <-started:
448	case <-time.After(time.Second):
449		t.Fatal("web search request did not start")
450	}
451	cancel()
452	result := wait(t, done)
453	if !result.isError || result.text != "web search: request cancelled" {
454		t.Fatalf("active cancellation result = (%q, %v)", result.text, result.isError)
455	}
456
457	started = make(chan struct{})
458	tool = &WebSearchTool{transport: webSearchRoundTripper(func(request *http.Request) (*http.Response, error) {
459		close(started)
460		<-request.Context().Done()
461		return nil, request.Context().Err()
462	})}
463	deadline, deadlineCancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
464	defer deadlineCancel()
465	done = call(tool, deadline)
466	select {
467	case <-started:
468	case <-time.After(time.Second):
469		t.Fatal("deadline request did not start")
470	}
471	result = wait(t, done)
472	if !result.isError || result.text != "web search: timed out" {
473		t.Fatalf("request deadline result = (%q, %v)", result.text, result.isError)
474	}
475}
476
477func TestWebSearchPreviewIsLocalAndOutputIsBounded(t *testing.T) {
478	called := false
479	tool := &WebSearchTool{transport: webSearchRoundTripper(func(*http.Request) (*http.Response, error) {
480		called = true
481		return nil, errors.New("unexpected request")
482	})}
483	raw := json.RawMessage(`{"query":"current Go release"}`)
484	preview, err := tool.Preview(context.Background(), raw)
485	if err != nil {
486		t.Fatal(err)
487	}
488	if preview.IsError || called {
489		t.Fatalf("preview error=%v called=%v", preview.IsError, called)
490	}
491	if got := webSearchText(t, preview.Content[0]); !strings.Contains(got, "DuckDuckGo HTML") || !strings.Contains(got, "current Go release") {
492		t.Fatalf("preview = %q", got)
493	}
494
495	results := make([]webSearchResult, webSearchMaxResults)
496	for i := range results {
497		results[i] = webSearchResult{
498			Title:   strings.Repeat("界", webSearchMaxTitleRunes+20),
499			URL:     "https://example.com/" + strings.Repeat("a", webSearchMaxURLBytes-32),
500			Snippet: strings.Repeat("界", webSearchMaxSnippetRunes+20),
501		}
502	}
503	output, visible, truncated := formatWebSearchResults(results)
504	if len(output) > webSearchMaxOutputBytes || visible == 0 || !truncated {
505		t.Fatalf("output bounds = len %d, visible %d, truncated %v", len(output), visible, truncated)
506	}
507	if got := boundWebSearchText(strings.Repeat("界", webSearchMaxTitleRunes+1), webSearchMaxTitleRunes); len([]rune(got)) != webSearchMaxTitleRunes || !strings.HasSuffix(got, "…") {
508		t.Fatalf("rune bound = %q", got)
509	}
510}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close