From 1de584e102109cd410cfdc0fdbf09cb31433e0f9 Mon Sep 17 00:00:00 2001 From: Philip Zeyliger Date: Fri, 16 Jan 2026 02:31:13 +0000 Subject: [PATCH] shelley: browser: support file downloads and large output handling Prompt: Handle https://github.com/boldsoftware/shelley/issues/7 - support browser downloads and write large outputs to files. --- Add support for browser-initiated file downloads and improve handling of large tool outputs. **Downloads:** - Browser now saves downloaded files to /tmp/shelley-downloads/ with randomized filenames (e.g., filename_abc12345.ext) - Downloads are reported in navigate/eval tool output with filename, source URL, and save path - Navigation to download URLs (like direct PDF links) now reports success with download info instead of ERR_ABORTED error **Large output handling:** - Console logs (browser_recent_console_logs) exceeding 1KB are written to /tmp/shelley-console-logs/ and the tool returns the file path - JavaScript results (browser_eval) exceeding 1KB are similarly written to files instead of returned inline Example output for downloads: Navigation triggered download(s): - report.pdf (from https://example.com/report.pdf) saved to: /tmp/shelley-downloads/report_a1b2c3d4.pdf Example output for large JS results: JavaScript result (5000 bytes) written to: /tmp/shelley-console-logs/js_result_x1y2z3.json Use `cat ...` to view the full content. Fixes https://github.com/boldsoftware/shelley/issues/7 Inspired by https://github.com/boldsoftware/shelley/pull/8 Co-authored-by: Shelley --- claudetool/browse/browse.go | 207 ++++++++++++- claudetool/browse/browse_test.go | 480 +++++++++++++++++++++++++++++++ 2 files changed, 680 insertions(+), 7 deletions(-) diff --git a/claudetool/browse/browse.go b/claudetool/browse/browse.go index bfa241ee570b0bd261a2d16ce8a1f15af6b69dd8..359bf869fd8ab978f00f848051e2ed92a1f1f31b 100644 --- a/claudetool/browse/browse.go +++ b/claudetool/browse/browse.go @@ -15,6 +15,7 @@ import ( "sync" "time" + "github.com/chromedp/cdproto/browser" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "github.com/google/uuid" @@ -25,9 +26,28 @@ import ( // ScreenshotDir is the directory where screenshots are stored const ScreenshotDir = "/tmp/shelley-screenshots" +// DownloadDir is the directory where downloads are stored +const DownloadDir = "/tmp/shelley-downloads" + +// ConsoleLogsDir is the directory where large console logs are stored +const ConsoleLogsDir = "/tmp/shelley-console-logs" + +// ConsoleLogSizeThreshold is the size in bytes above which console logs are written to a file +const ConsoleLogSizeThreshold = 1024 + // DefaultIdleTimeout is how long to wait before shutting down an idle browser const DefaultIdleTimeout = 30 * time.Minute +// DownloadInfo tracks information about a completed download +type DownloadInfo struct { + GUID string + URL string + SuggestedFilename string + FinalPath string + Completed bool + Error string +} + // BrowseTools contains all browser tools and manages a shared browser instance type BrowseTools struct { ctx context.Context @@ -48,6 +68,10 @@ type BrowseTools struct { idleTimer *time.Timer // Max image dimension for resizing (0 means use default) maxImageDimension int + // Download tracking + downloads map[string]*DownloadInfo // keyed by GUID + downloadsMutex sync.Mutex + downloadCond *sync.Cond } // NewBrowseTools creates a new set of browser automation tools. @@ -57,18 +81,23 @@ func NewBrowseTools(ctx context.Context, idleTimeout time.Duration, maxImageDime if idleTimeout <= 0 { idleTimeout = DefaultIdleTimeout } - if err := os.MkdirAll(ScreenshotDir, 0o755); err != nil { - log.Printf("Failed to create screenshot directory: %v", err) + for _, dir := range []string{ScreenshotDir, DownloadDir, ConsoleLogsDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Printf("Failed to create directory %s: %v", dir, err) + } } - return &BrowseTools{ + bt := &BrowseTools{ ctx: ctx, screenshots: make(map[string]time.Time), consoleLogs: make([]*runtime.EventConsoleAPICalled, 0), maxConsoleLogs: 100, maxImageDimension: maxImageDimension, idleTimeout: idleTimeout, + downloads: make(map[string]*DownloadInfo), } + bt.downloadCond = sync.NewCond(&bt.downloadsMutex) + return bt } // GetBrowserContext returns the browser context, initializing if needed and resetting the idle timer. @@ -96,10 +125,15 @@ func (b *BrowseTools) GetBrowserContext() (context.Context, error) { chromedp.WithBrowserOption(chromedp.WithDialTimeout(60*time.Second)), ) - // Set up console log listener + // Set up event listeners for console logs and downloads chromedp.ListenTarget(browserCtx, func(ev any) { - if e, ok := ev.(*runtime.EventConsoleAPICalled); ok { + switch e := ev.(type) { + case *runtime.EventConsoleAPICalled: b.captureConsoleLog(e) + case *browser.EventDownloadWillBegin: + b.handleDownloadWillBegin(e) + case *browser.EventDownloadProgress: + b.handleDownloadProgress(e) } }) @@ -116,6 +150,17 @@ func (b *BrowseTools) GetBrowserContext() (context.Context, error) { return nil, fmt.Errorf("failed to set default viewport: %w", err) } + // Configure download behavior to allow downloads and emit events + if err := chromedp.Run(browserCtx, + browser.SetDownloadBehavior(browser.SetDownloadBehaviorBehaviorAllowAndName). + WithDownloadPath(DownloadDir). + WithEventsEnabled(true), + ); err != nil { + browserCancel() + allocCancel() + return nil, fmt.Errorf("failed to configure download behavior: %w", err) + } + b.allocCtx = allocCtx b.allocCancel = allocCancel b.browserCtx = browserCtx @@ -238,10 +283,29 @@ func (b *BrowseTools) navigateRun(ctx context.Context, m json.RawMessage) llm.To chromedp.WaitReady("body"), ) if err != nil { + // Navigation to download URLs fails with ERR_ABORTED, but the download may have succeeded. + // Wait briefly for download events to be processed, then check if we got any downloads. + if strings.Contains(err.Error(), "net::ERR_ABORTED") { + time.Sleep(500 * time.Millisecond) + downloads := b.GetRecentDownloads() + if len(downloads) > 0 { + // Download succeeded - report it instead of error + var sb strings.Builder + sb.WriteString("Navigation triggered download(s):") + for _, d := range downloads { + if d.Error != "" { + sb.WriteString(fmt.Sprintf("\n - %s (from %s): ERROR: %s", d.SuggestedFilename, d.URL, d.Error)) + } else { + sb.WriteString(fmt.Sprintf("\n - %s (from %s) saved to: %s", d.SuggestedFilename, d.URL, d.FinalPath)) + } + } + return llm.ToolOut{LLMContent: llm.TextContent(sb.String())} + } + } return llm.ErrorToolOut(err) } - return llm.ToolOut{LLMContent: llm.TextContent("done")} + return b.toolOutWithDownloads("done") } // ResizeTool definition @@ -382,7 +446,19 @@ func (b *BrowseTools) evalRun(ctx context.Context, m json.RawMessage) llm.ToolOu return llm.ErrorfToolOut("failed to marshal response: %w", err) } - return llm.ToolOut{LLMContent: llm.TextContent("" + string(response) + "")} + // If output exceeds threshold, write to file + if len(response) > ConsoleLogSizeThreshold { + filename := fmt.Sprintf("js_result_%s.json", uuid.New().String()[:8]) + filePath := filepath.Join(ConsoleLogsDir, filename) + if err := os.WriteFile(filePath, response, 0o644); err != nil { + return llm.ErrorfToolOut("failed to write JS result to file: %w", err) + } + return b.toolOutWithDownloads(fmt.Sprintf( + "JavaScript result (%d bytes) written to: %s\nUse `cat %s` to view the full content.", + len(response), filePath, filePath)) + } + + return b.toolOutWithDownloads("" + string(response) + "") } // ScreenshotTool definition @@ -648,6 +724,111 @@ func (b *BrowseTools) captureConsoleLog(e *runtime.EventConsoleAPICalled) { } } +// handleDownloadWillBegin handles the browser download start event +func (b *BrowseTools) handleDownloadWillBegin(e *browser.EventDownloadWillBegin) { + b.downloadsMutex.Lock() + defer b.downloadsMutex.Unlock() + + b.downloads[e.GUID] = &DownloadInfo{ + GUID: e.GUID, + URL: e.URL, + SuggestedFilename: e.SuggestedFilename, + } +} + +// handleDownloadProgress handles the browser download progress event +func (b *BrowseTools) handleDownloadProgress(e *browser.EventDownloadProgress) { + b.downloadsMutex.Lock() + defer b.downloadsMutex.Unlock() + + info, ok := b.downloads[e.GUID] + if !ok { + // Download started before we started tracking, create entry + info = &DownloadInfo{GUID: e.GUID} + b.downloads[e.GUID] = info + } + + switch e.State { + case browser.DownloadProgressStateCompleted: + info.Completed = true + // The file is downloaded with GUID as filename, rename to suggested filename with random suffix + guidPath := filepath.Join(DownloadDir, e.GUID) + finalName := b.generateDownloadFilename(info.SuggestedFilename) + finalPath := filepath.Join(DownloadDir, finalName) + // Retry rename a few times as file might still be being written + var renamed bool + for i := 0; i < 10; i++ { + if err := os.Rename(guidPath, finalPath); err == nil { + info.FinalPath = finalPath + renamed = true + break + } + time.Sleep(50 * time.Millisecond) + } + if !renamed { + // File might have different path or couldn't be renamed + if e.FilePath != "" { + info.FinalPath = e.FilePath + } else { + info.FinalPath = guidPath + } + } + b.downloadCond.Broadcast() + case browser.DownloadProgressStateCanceled: + info.Completed = true + info.Error = "download canceled" + b.downloadCond.Broadcast() + } +} + +// generateDownloadFilename creates a filename with randomness +func (b *BrowseTools) generateDownloadFilename(suggested string) string { + if suggested == "" { + suggested = "download" + } + // Extract extension if present + ext := filepath.Ext(suggested) + base := strings.TrimSuffix(suggested, ext) + // Add random suffix + randomSuffix := uuid.New().String()[:8] + return fmt.Sprintf("%s_%s%s", base, randomSuffix, ext) +} + +// GetRecentDownloads returns download info for recently completed downloads and clears the list +func (b *BrowseTools) GetRecentDownloads() []*DownloadInfo { + b.downloadsMutex.Lock() + defer b.downloadsMutex.Unlock() + + var completed []*DownloadInfo + for guid, info := range b.downloads { + if info.Completed { + completed = append(completed, info) + delete(b.downloads, guid) + } + } + return completed +} + +// toolOutWithDownloads creates a tool output that includes any completed downloads +func (b *BrowseTools) toolOutWithDownloads(message string) llm.ToolOut { + downloads := b.GetRecentDownloads() + if len(downloads) == 0 { + return llm.ToolOut{LLMContent: llm.TextContent(message)} + } + + var sb strings.Builder + sb.WriteString(message) + sb.WriteString("\n\nDownloads completed:") + for _, d := range downloads { + if d.Error != "" { + sb.WriteString(fmt.Sprintf("\n - %s (from %s): ERROR: %s", d.SuggestedFilename, d.URL, d.Error)) + } else { + sb.WriteString(fmt.Sprintf("\n - %s (from %s) saved to: %s", d.SuggestedFilename, d.URL, d.FinalPath)) + } + } + return llm.ToolOut{LLMContent: llm.TextContent(sb.String())} +} + // RecentConsoleLogsTool definition type recentConsoleLogsInput struct { Limit int `json:"limit,omitempty"` @@ -705,6 +886,18 @@ func (b *BrowseTools) recentConsoleLogsRun(ctx context.Context, m json.RawMessag return llm.ErrorfToolOut("failed to serialize logs: %w", err) } + // If output exceeds threshold, write to file + if len(logData) > ConsoleLogSizeThreshold { + filename := fmt.Sprintf("console_logs_%s.json", uuid.New().String()[:8]) + filePath := filepath.Join(ConsoleLogsDir, filename) + if err := os.WriteFile(filePath, logData, 0o644); err != nil { + return llm.ErrorfToolOut("failed to write console logs to file: %w", err) + } + return llm.ToolOut{LLMContent: llm.TextContent(fmt.Sprintf( + "Retrieved %d console log entries (%d bytes).\nOutput written to: %s\nUse `cat %s` to view the full content.", + len(logs), len(logData), filePath, filePath))} + } + // Format the logs var sb strings.Builder sb.WriteString(fmt.Sprintf("Retrieved %d console log entries:\n\n", len(logs))) diff --git a/claudetool/browse/browse_test.go b/claudetool/browse/browse_test.go index 293b5c690c85d35432e0e777478cc5aca97b80f1..60af4107c351fbce5351f74a0b903ba9f18ea6e2 100644 --- a/claudetool/browse/browse_test.go +++ b/claudetool/browse/browse_test.go @@ -9,6 +9,8 @@ import ( "image" "image/color" "image/png" + "net" + "net/http" "os" "path/filepath" "slices" @@ -16,7 +18,10 @@ import ( "testing" "time" + "github.com/chromedp/cdproto/browser" + "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" + "github.com/go-json-experiment/json/jsontext" "shelley.exe.dev/llm" ) @@ -648,3 +653,478 @@ func TestSaveScreenshotErrorPath(t *testing.T) { filePath := GetScreenshotPath(id) os.Remove(filePath) } + +// TestConsoleLogsWriteToFile tests that large console logs are written to file +func TestConsoleLogsWriteToFile(t *testing.T) { + ctx := context.Background() + tools := NewBrowseTools(ctx, 0, 0) + t.Cleanup(func() { + tools.Close() + }) + + // Manually add many console logs to exceed threshold + tools.consoleLogsMutex.Lock() + for i := 0; i < 50; i++ { + tools.consoleLogs = append(tools.consoleLogs, &runtime.EventConsoleAPICalled{ + Type: runtime.APITypeLog, + Args: []*runtime.RemoteObject{ + {Type: runtime.TypeString, Value: jsontext.Value(`"This is a long log message that will help exceed the 1KB threshold when repeated many times"`)}, + }, + }) + } + tools.consoleLogsMutex.Unlock() + + // Mock browser context to avoid actual browser initialization + tools.mux.Lock() + tools.browserCtx = ctx + tools.mux.Unlock() + + // Get console logs - should be written to file + input := []byte(`{}`) + toolOut := tools.recentConsoleLogsRun(ctx, input) + if toolOut.Error != nil { + t.Fatalf("Unexpected error: %v", toolOut.Error) + } + + resultText := toolOut.LLMContent[0].Text + if !strings.Contains(resultText, "Output written to:") { + t.Errorf("Expected output to be written to file, got: %s", resultText) + } + if !strings.Contains(resultText, ConsoleLogsDir) { + t.Errorf("Expected file path to contain %s, got: %s", ConsoleLogsDir, resultText) + } + + // Extract file path and verify file exists + parts := strings.Split(resultText, "Output written to: ") + if len(parts) < 2 { + t.Fatalf("Could not extract file path from: %s", resultText) + } + filePath := strings.Split(parts[1], "\n")[0] + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Errorf("Expected file to exist at %s", filePath) + } else { + // Clean up + os.Remove(filePath) + } +} + +// TestGenerateDownloadFilename tests filename generation with randomness +func TestGenerateDownloadFilename(t *testing.T) { + ctx := context.Background() + tools := NewBrowseTools(ctx, 0, 0) + t.Cleanup(func() { + tools.Close() + }) + + tests := []struct { + suggested string + prefix string + ext string + }{ + {"test.txt", "test_", ".txt"}, + {"document.pdf", "document_", ".pdf"}, + {"noextension", "noextension_", ""}, + {"", "download_", ""}, + {"file.tar.gz", "file.tar_", ".gz"}, + } + + for _, tt := range tests { + t.Run(tt.suggested, func(t *testing.T) { + result := tools.generateDownloadFilename(tt.suggested) + if !strings.HasPrefix(result, tt.prefix) { + t.Errorf("Expected prefix %q, got %q", tt.prefix, result) + } + if !strings.HasSuffix(result, tt.ext) { + t.Errorf("Expected suffix %q, got %q", tt.ext, result) + } + // Verify randomness (8 chars between prefix and extension) + withoutPrefix := strings.TrimPrefix(result, tt.prefix) + withoutExt := strings.TrimSuffix(withoutPrefix, tt.ext) + if len(withoutExt) != 8 { + t.Errorf("Expected 8 random chars, got %d in %q", len(withoutExt), result) + } + }) + } + + // Verify different calls produce different results + result1 := tools.generateDownloadFilename("test.txt") + result2 := tools.generateDownloadFilename("test.txt") + if result1 == result2 { + t.Errorf("Expected different filenames, got same: %s", result1) + } +} + +// TestDownloadTracking tests the download event handling +func TestDownloadTracking(t *testing.T) { + ctx := context.Background() + tools := NewBrowseTools(ctx, 0, 0) + t.Cleanup(func() { + tools.Close() + }) + + // Simulate download start event + tools.handleDownloadWillBegin(&browser.EventDownloadWillBegin{ + GUID: "test-guid-123", + URL: "http://example.com/file.txt", + SuggestedFilename: "file.txt", + }) + + // Verify download is tracked + tools.downloadsMutex.Lock() + info, exists := tools.downloads["test-guid-123"] + tools.downloadsMutex.Unlock() + + if !exists { + t.Fatal("Expected download to be tracked") + } + if info.URL != "http://example.com/file.txt" { + t.Errorf("Expected URL %q, got %q", "http://example.com/file.txt", info.URL) + } + if info.Completed { + t.Error("Download should not be completed yet") + } + + // Simulate download progress - canceled + tools.handleDownloadProgress(&browser.EventDownloadProgress{ + GUID: "test-guid-123", + State: browser.DownloadProgressStateCanceled, + }) + + // Verify download is marked as completed with error + tools.downloadsMutex.Lock() + info = tools.downloads["test-guid-123"] + tools.downloadsMutex.Unlock() + + if !info.Completed { + t.Error("Download should be completed after cancel") + } + if info.Error != "download canceled" { + t.Errorf("Expected error %q, got %q", "download canceled", info.Error) + } +} + +// TestToolOutWithDownloads tests the download info appending to tool output +func TestToolOutWithDownloads(t *testing.T) { + ctx := context.Background() + tools := NewBrowseTools(ctx, 0, 0) + t.Cleanup(func() { + tools.Close() + }) + + // Test with no downloads + out := tools.toolOutWithDownloads("test message") + if out.LLMContent[0].Text != "test message" { + t.Errorf("Expected %q, got %q", "test message", out.LLMContent[0].Text) + } + + // Add a completed download + tools.downloadsMutex.Lock() + tools.downloads["guid1"] = &DownloadInfo{ + GUID: "guid1", + URL: "http://example.com/files/test.txt", + SuggestedFilename: "test.txt", + FinalPath: "/tmp/test_abc123.txt", + Completed: true, + } + tools.downloadsMutex.Unlock() + + // Test with downloads + out = tools.toolOutWithDownloads("done") + result := out.LLMContent[0].Text + if !strings.Contains(result, "Downloads completed:") { + t.Errorf("Expected downloads section, got: %s", result) + } + if !strings.Contains(result, "test.txt") { + t.Errorf("Expected filename in output, got: %s", result) + } + if !strings.Contains(result, "http://example.com/files/test.txt") { + t.Errorf("Expected URL in output, got: %s", result) + } + if !strings.Contains(result, "saved to:") { + t.Errorf("Expected 'saved to:' in output, got: %s", result) + } + if !strings.Contains(result, "/tmp/test_abc123.txt") { + t.Errorf("Expected final path in output, got: %s", result) + } + + // Verify download was cleared after retrieval + tools.downloadsMutex.Lock() + _, exists := tools.downloads["guid1"] + tools.downloadsMutex.Unlock() + if exists { + t.Error("Expected download to be cleared after retrieval") + } +} + +// TestBrowserDownload tests the full browser download workflow with a real HTTP server +func TestBrowserDownload(t *testing.T) { + if testing.Short() { + t.Skip("skipping browser download test in short mode") + } + + // Start a test HTTP server that triggers a download + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to start listener: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + + mux := http.NewServeMux() + mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Disposition", "attachment; filename=\"test.txt\"") + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("Hello, this is a test file!")) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(fmt.Sprintf(` + + +Download + +`))) + }) + + server := &http.Server{Handler: mux} + go server.Serve(listener) + defer server.Close() + + // Create browser tools + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + tools := NewBrowseTools(ctx, 0, 0) + t.Cleanup(func() { + tools.Close() + }) + + // Navigate to the test page + navInput := []byte(fmt.Sprintf(`{"url": "http://127.0.0.1:%d/"}`, port)) + toolOut := tools.NewNavigateTool().Run(ctx, navInput) + if toolOut.Error != nil { + if strings.Contains(toolOut.Error.Error(), "failed to start browser") { + t.Skip("Browser automation not available in this environment") + } + t.Fatalf("Navigation error: %v", toolOut.Error) + } + + // Click the download link + evalInput := []byte(`{"expression": "document.getElementById('download-link').click()"}`) + toolOut = tools.NewEvalTool().Run(ctx, evalInput) + if toolOut.Error != nil { + t.Fatalf("Eval error: %v", toolOut.Error) + } + + // Wait for download to complete (poll for completion) + var downloadFound bool + for i := 0; i < 20; i++ { + time.Sleep(100 * time.Millisecond) + files, err := os.ReadDir(DownloadDir) + if err != nil { + continue + } + for _, f := range files { + // Check for renamed file (test_*) or GUID file + if strings.HasPrefix(f.Name(), "test_") || len(f.Name()) == 36 { + filePath := filepath.Join(DownloadDir, f.Name()) + content, err := os.ReadFile(filePath) + if err == nil && string(content) == "Hello, this is a test file!" { + downloadFound = true + t.Logf("Found downloaded file: %s", f.Name()) + // Clean up + os.Remove(filePath) + break + } + } + } + if downloadFound { + break + } + } + + if !downloadFound { + // List what's in the directory for debugging + files, _ := os.ReadDir(DownloadDir) + var names []string + for _, f := range files { + names = append(names, f.Name()) + } + t.Errorf("Download file not found. Files in %s: %v", DownloadDir, names) + } +} + +// TestBrowserDownloadReported tests that downloads are reported in tool output +func TestBrowserDownloadReported(t *testing.T) { + if testing.Short() { + t.Skip("skipping browser download test in short mode") + } + + // Start a test HTTP server that triggers a download + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to start listener: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + + mux := http.NewServeMux() + mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Disposition", "attachment; filename=\"report_test.txt\"") + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("Download report test file content")) + }) + + server := &http.Server{Handler: mux} + go server.Serve(listener) + defer server.Close() + + // Create browser tools + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + tools := NewBrowseTools(ctx, 0, 0) + t.Cleanup(func() { + tools.Close() + }) + + // Navigate directly to the download URL - should succeed with download info + navInput := []byte(fmt.Sprintf(`{"url": "http://127.0.0.1:%d/download"}`, port)) + toolOut := tools.NewNavigateTool().Run(ctx, navInput) + if toolOut.Error != nil { + if strings.Contains(toolOut.Error.Error(), "failed to start browser") { + t.Skip("Browser automation not available in this environment") + } + t.Fatalf("Navigation returned unexpected error: %v", toolOut.Error) + } + + result := toolOut.LLMContent[0].Text + t.Logf("Navigation result: %s", result) + + // Navigation to download URL should report the download directly + if !strings.Contains(result, "download") { + t.Errorf("Expected 'download' in output, got: %s", result) + } + if !strings.Contains(result, "report_test") { + t.Errorf("Expected 'report_test' in download output, got: %s", result) + } + if !strings.Contains(result, DownloadDir) { + t.Errorf("Expected download path, got: %s", result) + } + + // Clean up any downloaded files + files, _ := os.ReadDir(DownloadDir) + for _, f := range files { + if strings.HasPrefix(f.Name(), "report_test_") { + os.Remove(filepath.Join(DownloadDir, f.Name())) + } + } +} + +// TestLargeJSOutputWriteToFile tests that large JS eval results are written to file +func TestLargeJSOutputWriteToFile(t *testing.T) { + if testing.Short() { + t.Skip("skipping browser test in short mode") + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + tools := NewBrowseTools(ctx, 0, 0) + t.Cleanup(func() { + tools.Close() + }) + + // Navigate to about:blank first + navInput := []byte(`{"url": "about:blank"}`) + toolOut := tools.NewNavigateTool().Run(ctx, navInput) + if toolOut.Error != nil { + if strings.Contains(toolOut.Error.Error(), "failed to start browser") { + t.Skip("Browser automation not available in this environment") + } + t.Fatalf("Navigation error: %v", toolOut.Error) + } + + // Execute JS that returns a large string (> 1KB) + evalInput := []byte(`{"expression": "'x'.repeat(2000)"}`) + toolOut = tools.NewEvalTool().Run(ctx, evalInput) + if toolOut.Error != nil { + t.Fatalf("Eval error: %v", toolOut.Error) + } + + result := toolOut.LLMContent[0].Text + t.Logf("Result: %s", result[:min(200, len(result))]) + + // Should be written to file + if !strings.Contains(result, "JavaScript result") { + t.Errorf("Expected 'JavaScript result' in output, got: %s", result) + } + if !strings.Contains(result, "written to:") { + t.Errorf("Expected 'written to:' in output, got: %s", result) + } + if !strings.Contains(result, ConsoleLogsDir) { + t.Errorf("Expected file path to contain %s, got: %s", ConsoleLogsDir, result) + } + + // Extract and verify file exists + parts := strings.Split(result, "written to: ") + if len(parts) >= 2 { + filePath := strings.Split(parts[1], "\n")[0] + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Errorf("Expected file to exist at %s", filePath) + } else { + // Verify content + content, err := os.ReadFile(filePath) + if err != nil { + t.Errorf("Failed to read file: %v", err) + } else if len(content) < 2000 { + t.Errorf("Expected file to contain large result, got %d bytes", len(content)) + } + // Clean up + os.Remove(filePath) + } + } +} + +// TestSmallJSOutputInline tests that small JS results are returned inline +func TestSmallJSOutputInline(t *testing.T) { + if testing.Short() { + t.Skip("skipping browser test in short mode") + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + tools := NewBrowseTools(ctx, 0, 0) + t.Cleanup(func() { + tools.Close() + }) + + // Navigate to about:blank first + navInput := []byte(`{"url": "about:blank"}`) + toolOut := tools.NewNavigateTool().Run(ctx, navInput) + if toolOut.Error != nil { + if strings.Contains(toolOut.Error.Error(), "failed to start browser") { + t.Skip("Browser automation not available in this environment") + } + t.Fatalf("Navigation error: %v", toolOut.Error) + } + + // Execute JS that returns a small string (< 1KB) + evalInput := []byte(`{"expression": "'hello world'"}`) + toolOut = tools.NewEvalTool().Run(ctx, evalInput) + if toolOut.Error != nil { + t.Fatalf("Eval error: %v", toolOut.Error) + } + + result := toolOut.LLMContent[0].Text + + // Should be inline + if !strings.Contains(result, "") { + t.Errorf("Expected '' in output, got: %s", result) + } + if !strings.Contains(result, "hello world") { + t.Errorf("Expected 'hello world' in output, got: %s", result) + } + if strings.Contains(result, "written to:") { + t.Errorf("Small result should not be written to file, got: %s", result) + } +}