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}