package tools
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/bnema/zut/packages/provider"
)
type webSearchRoundTripper func(*http.Request) (*http.Response, error)
func (f webSearchRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
func webSearchDirectTestTransport(t *testing.T) http.RoundTripper {
t.Helper()
transport := &http.Transport{Proxy: nil}
t.Cleanup(transport.CloseIdleConnections)
return transport
}
func webSearchText(t *testing.T, resultText any) string {
t.Helper()
result, ok := resultText.(provider.TextBlock)
if !ok {
t.Fatalf("content block = %T, want provider.TextBlock", resultText)
}
return result.Text
}
func executeWebSearch(t *testing.T, tool *WebSearchTool, ctx context.Context, args any) (string, bool, any) {
t.Helper()
raw, err := json.Marshal(args)
if err != nil {
t.Fatal(err)
}
return executeWebSearchRaw(t, tool, ctx, raw)
}
func executeWebSearchRaw(t *testing.T, tool *WebSearchTool, ctx context.Context, raw json.RawMessage) (string, bool, any) {
t.Helper()
result, err := tool.Execute(ctx, raw, nil)
if err != nil {
t.Fatal(err)
}
if len(result.Content) != 1 {
t.Fatalf("content = %#v", result.Content)
}
return webSearchText(t, result.Content[0]), result.IsError, result.Details
}
func webSearchHTMLResponse(request *http.Request, body string) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
Body: io.NopCloser(strings.NewReader(body)),
Request: request,
}
}
const webSearchFixture = `
`
func TestWebSearchDescriptionAvoidsSearchOperators(t *testing.T) {
description := NewWebSearchTool().Description()
for _, want := range []string{"ordinary keyword queries", "site:"} {
if !strings.Contains(description, want) {
t.Errorf("description = %q, want %q", description, want)
}
}
}
func TestWebSearchRequestAndResultContract(t *testing.T) {
var calls atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
if r.URL.Path != "/html/" {
t.Errorf("path = %q, want /html/", r.URL.Path)
}
if got, want := r.URL.RawQuery, "q=Go+JSON-RPC+%E2%9C%93"; got != want {
t.Errorf("query = %q, want %q", got, want)
}
if got := r.Header.Get("User-Agent"); got != webSearchUserAgent {
t.Errorf("User-Agent = %q", got)
}
if got := r.Header.Get("Cookie"); got != "" {
t.Errorf("Cookie = %q, want none", got)
}
w.Header().Set("Set-Cookie", "session=should-not-be-reused")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(webSearchFixture))
}))
defer server.Close()
tool := &WebSearchTool{transport: webSearchDirectTestTransport(t), endpointOverride: server.URL + "/html/?ignored=value"}
text, isError, details := executeWebSearch(t, tool, context.Background(), map[string]any{
"query": " Go JSON-RPC ✓ ",
"max_results": 2,
})
if isError {
t.Fatalf("unexpected error: %s", text)
}
if got := calls.Load(); got != 1 {
t.Fatalf("calls = %d, want 1", got)
}
if !strings.HasPrefix(text, webSearchDisclosure+"\n\n[1] One result\n https://example.com/one\n First snippet.") {
t.Fatalf("unexpected result text:\n%s", text)
}
if strings.Contains(text, "#fragment") || !strings.Contains(text, "https://example.org/two") {
t.Fatalf("result URLs were not normalized:\n%s", text)
}
metadata, ok := details.(map[string]any)
if !ok || metadata["backend"] != "DuckDuckGo HTML" || metadata["query"] != "Go JSON-RPC ✓" {
t.Fatalf("details = %#v", details)
}
if count, ok := metadata["result_count"].(int); !ok || count != 2 {
t.Fatalf("result_count = %#v", metadata["result_count"])
}
}
func TestWebSearchEndpointIsFixed(t *testing.T) {
var gotURL string
tool := NewWebSearchTool()
tool.transport = webSearchRoundTripper(func(request *http.Request) (*http.Response, error) {
gotURL = request.URL.String()
return webSearchHTMLResponse(request, webSearchFixture), nil
})
text, isError, _ := executeWebSearch(t, tool, context.Background(), map[string]any{"query": "fixed"})
if isError {
t.Fatalf("unexpected error: %s", text)
}
if gotURL != webSearchEndpoint+"?q=fixed" {
t.Fatalf("request URL = %q, want %q", gotURL, webSearchEndpoint+"?q=fixed")
}
}
func TestWebSearchUsesConfiguredProxyTransport(t *testing.T) {
var proxyCalls atomic.Int64
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyCalls.Add(1)
if r.URL.Scheme != "http" || r.URL.Host != "web-search.invalid" {
t.Errorf("proxy request URL = %q, want absolute web-search.invalid URL", r.URL)
}
if got, want := r.URL.Path, "/html/"; got != want {
t.Errorf("proxy request path = %q, want %q", got, want)
}
if got, want := r.URL.RawQuery, "q=environment"; got != want {
t.Errorf("proxy request query = %q, want %q", got, want)
}
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(webSearchFixture))
}))
defer proxy.Close()
proxyURL, err := url.Parse(proxy.URL)
if err != nil {
t.Fatal(err)
}
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
t.Cleanup(transport.CloseIdleConnections)
tool := &WebSearchTool{
endpointOverride: "http://web-search.invalid/html/",
transport: transport,
}
text, isError, _ := executeWebSearch(t, tool, context.Background(), map[string]any{"query": "environment"})
if isError {
t.Fatalf("unexpected error through configured proxy: %s", text)
}
if got := proxyCalls.Load(); got != 1 {
t.Fatalf("proxy calls = %d, want 1", got)
}
}
func TestWebSearchExecuteClosesOwnedTransportConnections(t *testing.T) {
const calls = 3
closed := make(chan struct{}, calls)
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(webSearchFixture))
}))
server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
if state == http.StateClosed {
closed <- struct{}{}
}
}
server.Start()
defer server.Close()
// ProxyFromEnvironment always bypasses localhost. This keeps this regression
// test local even after the proxy contract test initializes its cached proxy.
endpoint := strings.Replace(server.URL, "127.0.0.1", "localhost", 1)
tool := &WebSearchTool{endpointOverride: endpoint}
for i := 0; i < calls; i++ {
text, isError, _ := executeWebSearch(t, tool, context.Background(), map[string]any{"query": "lifecycle"})
if isError {
t.Fatalf("call %d returned an error: %s", i+1, text)
}
}
for i := 0; i < calls; i++ {
select {
case <-closed:
case <-time.After(time.Second):
t.Fatalf("call %d left its transport connection open", i+1)
}
}
}
func TestWebSearchSanitizesResultsAndDetails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(""))
}))
defer server.Close()
tool := &WebSearchTool{transport: webSearchDirectTestTransport(t), endpointOverride: server.URL}
text, isError, details := executeWebSearch(t, tool, context.Background(), map[string]any{"query": "safe\x1b[31m query\x00"})
if isError {
t.Fatalf("unexpected error: %s", text)
}
if want := webSearchDisclosure + "\n\n[1] Red title\n https://example.com\n Clean snippet\n"; text != want {
t.Fatalf("sanitized text = %q, want %q", text, want)
}
metadata, ok := details.(map[string]any)
if !ok {
t.Fatalf("details = %#v", details)
}
if metadata["query"] != "safe query" {
t.Fatalf("sanitized query details = %#v", metadata["query"])
}
results, ok := metadata["results"].([]webSearchResult)
if !ok || len(results) != 1 {
t.Fatalf("sanitized result details = %#v", metadata["results"])
}
if results[0].Title != "Red title" || results[0].Snippet != "Clean snippet" {
t.Fatalf("sanitized result = %#v", results[0])
}
if strings.ContainsAny(text+results[0].Title+results[0].Snippet+metadata["query"].(string), "\x00\x01\x1b") {
t.Fatalf("control character remained in model-visible output")
}
}
func TestWebSearchRejectsInvalidArgumentsWithoutRequest(t *testing.T) {
tool := &WebSearchTool{transport: webSearchRoundTripper(func(*http.Request) (*http.Response, error) {
t.Fatal("unexpected request")
return nil, nil
})}
cases := []struct {
name string
args any
want string
}{
{name: "missing query", args: map[string]any{}, want: "web search: invalid query"},
{name: "empty query", args: map[string]any{"query": " \t "}, want: "web search: invalid query"},
{name: "long query", args: map[string]any{"query": strings.Repeat("界", webSearchMaxQueryRunes+1)}, want: "web search: invalid query"},
{name: "zero results", args: map[string]any{"query": "x", "max_results": 0}, want: "web search: invalid max_results"},
{name: "too many results", args: map[string]any{"query": "x", "max_results": 11}, want: "web search: invalid max_results"},
{name: "noninteger results", args: map[string]any{"query": "x", "max_results": "2"}, want: "web search: invalid max_results"},
{name: "unknown key", args: map[string]any{"query": "x", "unexpected": true}, want: "web search: invalid query"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
text, isError, _ := executeWebSearch(t, tool, context.Background(), tc.args)
if !isError || text != tc.want {
t.Fatalf("result = (%q, %v), want (%q, true)", text, isError, tc.want)
}
})
}
largeRaw := json.RawMessage(strings.Repeat(" ", webSearchMaxInputBytes) + `{"query":"x"}`)
text, isError, _ := executeWebSearchRaw(t, tool, context.Background(), largeRaw)
if !isError || text != "web search: invalid query" {
t.Fatalf("large raw input result = (%q, %v)", text, isError)
}
}
func TestWebSearchBackendFailuresAreSanitized(t *testing.T) {
cases := []struct {
name string
status int
content string
body string
want string
}{
{name: "rate limited", status: http.StatusTooManyRequests, content: "text/html", body: "private backend diagnostics", want: "web search: DuckDuckGo returned an unavailable response"},
{name: "wrong content type", status: http.StatusOK, content: "application/json", body: `{}`, want: "web search: DuckDuckGo returned an unavailable response"},
{name: "challenge", status: http.StatusOK, content: "text/html", body: "unusual traffic captcha", want: "web search: DuckDuckGo returned an unavailable response"},
{name: "unparseable", status: http.StatusOK, content: "text/html", body: "ordinary page", want: "web search: DuckDuckGo returned an unparseable response"},
{name: "no usable", status: http.StatusOK, content: "text/html", body: ``, want: "web search: no usable HTTP(S) results"},
{name: "malformed redirect", status: http.StatusOK, content: "text/html", body: ``, want: "web search: no usable HTTP(S) results"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", tc.content)
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
defer server.Close()
tool := &WebSearchTool{transport: webSearchDirectTestTransport(t), endpointOverride: server.URL}
text, isError, _ := executeWebSearch(t, tool, context.Background(), map[string]any{"query": "test"})
if !isError || text != tc.want {
t.Fatalf("result = (%q, %v), want (%q, true)", text, isError, tc.want)
}
})
}
}
func TestWebSearchDoesNotUseCookies(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Cookie"); got != "" {
t.Errorf("Cookie = %q, want none", got)
}
w.Header().Set("Set-Cookie", "session=private")
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(webSearchFixture))
}))
defer server.Close()
tool := &WebSearchTool{transport: webSearchDirectTestTransport(t), endpointOverride: server.URL}
for i := 0; i < 2; i++ {
text, isError, _ := executeWebSearch(t, tool, context.Background(), map[string]any{"query": "cookie"})
if isError {
t.Fatalf("unexpected error: %s", text)
}
}
if client := tool.client(); client.Jar != nil {
t.Fatalf("client jar = %v, want nil", client.Jar)
}
}
func TestWebSearchRejectsRedirectAndOversizedResponse(t *testing.T) {
var followed atomic.Bool
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/next" {
followed.Store(true)
}
http.Redirect(w, r, "/next", http.StatusFound)
}))
defer redirect.Close()
tool := &WebSearchTool{transport: webSearchDirectTestTransport(t), endpointOverride: redirect.URL}
text, isError, _ := executeWebSearch(t, tool, context.Background(), map[string]any{"query": "test"})
if !isError || text != "web search: DuckDuckGo request failed" {
t.Fatalf("redirect result = (%q, %v)", text, isError)
}
if followed.Load() {
t.Fatal("redirect was followed")
}
oversized := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(strings.Repeat("x", webSearchMaxBodyBytes+1)))
}))
defer oversized.Close()
tool = &WebSearchTool{transport: webSearchDirectTestTransport(t), endpointOverride: oversized.URL}
text, isError, _ = executeWebSearch(t, tool, context.Background(), map[string]any{"query": "test"})
if !isError || text != "web search: DuckDuckGo returned an unavailable response" {
t.Fatalf("oversized result = (%q, %v)", text, isError)
}
}
func TestWebSearchCancellationAndTransportFailure(t *testing.T) {
cancelled, cancel := context.WithCancel(context.Background())
cancel()
tool := &WebSearchTool{}
text, isError, _ := executeWebSearch(t, tool, cancelled, map[string]any{"query": "test"})
if !isError || text != "web search: request cancelled" {
t.Fatalf("cancelled result = (%q, %v)", text, isError)
}
deadline, deadlineCancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer deadlineCancel()
text, isError, _ = executeWebSearch(t, tool, deadline, map[string]any{"query": "test"})
if !isError || text != "web search: timed out" {
t.Fatalf("deadline result = (%q, %v)", text, isError)
}
tool = &WebSearchTool{transport: webSearchRoundTripper(func(*http.Request) (*http.Response, error) {
return nil, errors.New("dial private proxy failed")
})}
text, isError, _ = executeWebSearch(t, tool, context.Background(), map[string]any{"query": "test"})
if !isError || text != "web search: DuckDuckGo request failed" || strings.Contains(text, "proxy") {
t.Fatalf("transport result = (%q, %v)", text, isError)
}
}
func TestWebSearchActiveCancellationAndRequestDeadline(t *testing.T) {
type asyncResult struct {
text string
isError bool
}
call := func(tool *WebSearchTool, ctx context.Context) <-chan asyncResult {
done := make(chan asyncResult, 1)
go func() {
result, err := tool.Execute(ctx, json.RawMessage(`{"query":"active"}`), nil)
if err != nil || len(result.Content) != 1 {
done <- asyncResult{text: "unexpected tool error", isError: true}
return
}
block, ok := result.Content[0].(provider.TextBlock)
if !ok {
done <- asyncResult{text: "unexpected content", isError: true}
return
}
done <- asyncResult{text: block.Text, isError: result.IsError}
}()
return done
}
wait := func(t *testing.T, done <-chan asyncResult) asyncResult {
t.Helper()
select {
case result := <-done:
return result
case <-time.After(time.Second):
t.Fatal("web search did not finish after context termination")
return asyncResult{}
}
}
started := make(chan struct{})
tool := &WebSearchTool{transport: webSearchRoundTripper(func(request *http.Request) (*http.Response, error) {
close(started)
<-request.Context().Done()
return nil, request.Context().Err()
})}
ctx, cancel := context.WithCancel(context.Background())
done := call(tool, ctx)
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("web search request did not start")
}
cancel()
result := wait(t, done)
if !result.isError || result.text != "web search: request cancelled" {
t.Fatalf("active cancellation result = (%q, %v)", result.text, result.isError)
}
started = make(chan struct{})
tool = &WebSearchTool{transport: webSearchRoundTripper(func(request *http.Request) (*http.Response, error) {
close(started)
<-request.Context().Done()
return nil, request.Context().Err()
})}
deadline, deadlineCancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
defer deadlineCancel()
done = call(tool, deadline)
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("deadline request did not start")
}
result = wait(t, done)
if !result.isError || result.text != "web search: timed out" {
t.Fatalf("request deadline result = (%q, %v)", result.text, result.isError)
}
}
func TestWebSearchPreviewIsLocalAndOutputIsBounded(t *testing.T) {
called := false
tool := &WebSearchTool{transport: webSearchRoundTripper(func(*http.Request) (*http.Response, error) {
called = true
return nil, errors.New("unexpected request")
})}
raw := json.RawMessage(`{"query":"current Go release"}`)
preview, err := tool.Preview(context.Background(), raw)
if err != nil {
t.Fatal(err)
}
if preview.IsError || called {
t.Fatalf("preview error=%v called=%v", preview.IsError, called)
}
if got := webSearchText(t, preview.Content[0]); !strings.Contains(got, "DuckDuckGo HTML") || !strings.Contains(got, "current Go release") {
t.Fatalf("preview = %q", got)
}
results := make([]webSearchResult, webSearchMaxResults)
for i := range results {
results[i] = webSearchResult{
Title: strings.Repeat("界", webSearchMaxTitleRunes+20),
URL: "https://example.com/" + strings.Repeat("a", webSearchMaxURLBytes-32),
Snippet: strings.Repeat("界", webSearchMaxSnippetRunes+20),
}
}
output, visible, truncated := formatWebSearchResults(results)
if len(output) > webSearchMaxOutputBytes || visible == 0 || !truncated {
t.Fatalf("output bounds = len %d, visible %d, truncated %v", len(output), visible, truncated)
}
if got := boundWebSearchText(strings.Repeat("界", webSearchMaxTitleRunes+1), webSearchMaxTitleRunes); len([]rune(got)) != webSearchMaxTitleRunes || !strings.HasSuffix(got, "…") {
t.Fatalf("rune bound = %q", got)
}
}