1package browse
2
3import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "image"
10 "image/color"
11 "image/png"
12 "net"
13 "net/http"
14 "os"
15 "path/filepath"
16 "slices"
17 "strings"
18 "testing"
19 "time"
20
21 "github.com/chromedp/cdproto/browser"
22 "github.com/chromedp/cdproto/runtime"
23 "github.com/chromedp/chromedp"
24 "github.com/go-json-experiment/json/jsontext"
25 "shelley.exe.dev/llm"
26)
27
28func TestToolCreation(t *testing.T) {
29 // Create browser tools instance
30 tools := NewBrowseTools(context.Background(), 0, 0)
31 t.Cleanup(func() {
32 tools.Close()
33 })
34
35 // Test each tool has correct name and description
36 toolTests := []struct {
37 tool *llm.Tool
38 expectedName string
39 shortDesc string
40 requiredProps []string
41 }{
42 {tools.NewNavigateTool(), "browser_navigate", "Navigate", []string{"url"}},
43 {tools.NewEvalTool(), "browser_eval", "Evaluate", []string{"expression"}},
44 {tools.NewResizeTool(), "browser_resize", "Resize", []string{"width", "height"}},
45 {tools.NewScreenshotTool(), "browser_take_screenshot", "Take", nil},
46 }
47
48 for _, tt := range toolTests {
49 t.Run(tt.expectedName, func(t *testing.T) {
50 if tt.tool.Name != tt.expectedName {
51 t.Errorf("expected name %q, got %q", tt.expectedName, tt.tool.Name)
52 }
53
54 if !strings.Contains(tt.tool.Description, tt.shortDesc) {
55 t.Errorf("description %q should contain %q", tt.tool.Description, tt.shortDesc)
56 }
57
58 // Verify schema has required properties
59 if len(tt.requiredProps) > 0 {
60 var schema struct {
61 Required []string `json:"required"`
62 }
63 if err := json.Unmarshal(tt.tool.InputSchema, &schema); err != nil {
64 t.Fatalf("failed to unmarshal schema: %v", err)
65 }
66
67 for _, prop := range tt.requiredProps {
68 if !slices.Contains(schema.Required, prop) {
69 t.Errorf("property %q should be required", prop)
70 }
71 }
72 }
73 })
74 }
75}
76
77func TestGetTools(t *testing.T) {
78 // Create browser tools instance
79 tools := NewBrowseTools(context.Background(), 0, 0)
80 t.Cleanup(func() {
81 tools.Close()
82 })
83
84 // Test with screenshot tools included
85 t.Run("with screenshots", func(t *testing.T) {
86 toolsWithScreenshots := tools.GetTools(true)
87 if len(toolsWithScreenshots) != 7 {
88 t.Errorf("expected 7 tools with screenshots, got %d", len(toolsWithScreenshots))
89 }
90
91 // Check tool naming convention
92 for _, tool := range toolsWithScreenshots {
93 // Most tools have browser_ prefix, except for read_image
94 if tool.Name != "read_image" && !strings.HasPrefix(tool.Name, "browser_") {
95 t.Errorf("tool name %q does not have prefix 'browser_'", tool.Name)
96 }
97 }
98 })
99
100 // Test without screenshot tools
101 t.Run("without screenshots", func(t *testing.T) {
102 noScreenshotTools := tools.GetTools(false)
103 if len(noScreenshotTools) != 5 {
104 t.Errorf("expected 5 tools without screenshots, got %d", len(noScreenshotTools))
105 }
106 })
107}
108
109// TestBrowserInitialization verifies that the browser can start correctly
110func TestBrowserInitialization(t *testing.T) {
111 // Skip long tests in short mode
112 if testing.Short() {
113 t.Skip("skipping browser initialization test in short mode")
114 }
115
116 // Create browser tools instance
117 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
118 defer cancel()
119
120 tools := NewBrowseTools(ctx, 0, 0)
121 t.Cleanup(func() {
122 tools.Close()
123 })
124
125 // Get browser context (this initializes the browser)
126 browserCtx, err := tools.GetBrowserContext()
127 if err != nil {
128 if strings.Contains(err.Error(), "failed to start browser") {
129 t.Skip("Browser automation not available in this environment")
130 }
131 t.Fatalf("Failed to get browser context: %v", err)
132 }
133
134 // Try to navigate to a simple page
135 var title string
136 err = chromedp.Run(browserCtx,
137 chromedp.Navigate("about:blank"),
138 chromedp.Title(&title),
139 )
140 if err != nil {
141 t.Fatalf("Failed to navigate to about:blank: %v", err)
142 }
143
144 t.Logf("Successfully navigated to about:blank, title: %q", title)
145}
146
147// TestNavigateTool verifies that the navigate tool works correctly
148func TestNavigateTool(t *testing.T) {
149 // Skip long tests in short mode
150 if testing.Short() {
151 t.Skip("skipping navigate tool test in short mode")
152 }
153
154 // Create browser tools instance
155 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
156 defer cancel()
157
158 tools := NewBrowseTools(ctx, 0, 0)
159 t.Cleanup(func() {
160 tools.Close()
161 })
162
163 // Get the navigate tool
164 navTool := tools.NewNavigateTool()
165
166 // Create input for the navigate tool
167 input := map[string]string{"url": "https://example.com"}
168 inputJSON, _ := json.Marshal(input)
169
170 // Call the tool
171 toolOut := navTool.Run(ctx, []byte(inputJSON))
172 if toolOut.Error != nil {
173 t.Fatalf("Error running navigate tool: %v", toolOut.Error)
174 }
175 result := toolOut.LLMContent
176
177 // Verify the response is successful
178 resultText := result[0].Text
179 if !strings.Contains(resultText, "done") {
180 // If browser automation is not available, skip the test
181 if strings.Contains(resultText, "browser automation not available") {
182 t.Skip("Browser automation not available in this environment")
183 } else {
184 t.Fatalf("Expected done in result text, got: %s", resultText)
185 }
186 }
187
188 // Try to get the page title to verify the navigation worked
189 browserCtx, err := tools.GetBrowserContext()
190 if err != nil {
191 // If browser automation is not available, skip the test
192 if strings.Contains(err.Error(), "browser automation not available") {
193 t.Skip("Browser automation not available in this environment")
194 } else {
195 t.Fatalf("Failed to get browser context: %v", err)
196 }
197 }
198
199 var title string
200 err = chromedp.Run(browserCtx, chromedp.Title(&title))
201 if err != nil {
202 t.Fatalf("Failed to get page title: %v", err)
203 }
204
205 t.Logf("Successfully navigated to example.com, title: %q", title)
206 if title != "Example Domain" {
207 t.Errorf("Expected title 'Example Domain', got '%s'", title)
208 }
209}
210
211// TestScreenshotTool tests that the screenshot tool properly saves files
212func TestScreenshotTool(t *testing.T) {
213 // Create browser tools instance
214 ctx := context.Background()
215 tools := NewBrowseTools(ctx, 0, 0)
216 t.Cleanup(func() {
217 tools.Close()
218 })
219
220 // Test SaveScreenshot function directly
221 testData := []byte("test image data")
222 id := tools.SaveScreenshot(testData)
223 if id == "" {
224 t.Fatal("SaveScreenshot returned empty ID")
225 }
226
227 // Get the file path and check if the file exists
228 filePath := GetScreenshotPath(id)
229 _, err := os.Stat(filePath)
230 if err != nil {
231 t.Fatalf("Failed to find screenshot file: %v", err)
232 }
233
234 // Read the file contents
235 contents, err := os.ReadFile(filePath)
236 if err != nil {
237 t.Fatalf("Failed to read screenshot file: %v", err)
238 }
239
240 // Check the file contents
241 if string(contents) != string(testData) {
242 t.Errorf("File contents don't match: expected %q, got %q", string(testData), string(contents))
243 }
244
245 // Clean up the test file
246 os.Remove(filePath)
247}
248
249func TestReadImageTool(t *testing.T) {
250 // Create a test BrowseTools instance
251 ctx := context.Background()
252 browseTools := NewBrowseTools(ctx, 0, 0)
253 t.Cleanup(func() {
254 browseTools.Close()
255 })
256
257 // Create a test image
258 testDir := t.TempDir()
259 testImagePath := filepath.Join(testDir, "test_image.png")
260
261 // Create a small 1x1 black PNG image
262 smallPng := []byte{
263 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
264 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
265 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0x60, 0x00, 0x00, 0x00,
266 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
267 0x42, 0x60, 0x82,
268 }
269
270 // Write the test image
271 err := os.WriteFile(testImagePath, smallPng, 0o644)
272 if err != nil {
273 t.Fatalf("Failed to create test image: %v", err)
274 }
275
276 // Create the tool
277 readImageTool := browseTools.NewReadImageTool()
278
279 // Prepare input
280 input := fmt.Sprintf(`{"path": "%s"}`, testImagePath)
281
282 // Run the tool
283 toolOut := readImageTool.Run(ctx, []byte(input))
284 if toolOut.Error != nil {
285 t.Fatalf("Read image tool failed: %v", toolOut.Error)
286 }
287 result := toolOut.LLMContent
288
289 // In the updated code, result is already a []llm.Content
290 contents := result
291
292 // Check that we got at least two content objects
293 if len(contents) < 2 {
294 t.Fatalf("Expected at least 2 content objects, got %d", len(contents))
295 }
296
297 // Check that the second content has image data
298 if contents[1].MediaType == "" {
299 t.Errorf("Expected MediaType in second content")
300 }
301
302 if contents[1].Data == "" {
303 t.Errorf("Expected Data in second content")
304 }
305}
306
307// TestDefaultViewportSize verifies that the browser starts with the correct default viewport size
308func TestDefaultViewportSize(t *testing.T) {
309 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
310 defer cancel()
311
312 // Skip if CI or headless testing environment
313 if os.Getenv("CI") != "" || os.Getenv("HEADLESS_TEST") != "" {
314 t.Skip("Skipping browser test in CI/headless environment")
315 }
316
317 tools := NewBrowseTools(ctx, 0, 0)
318 t.Cleanup(func() {
319 tools.Close()
320 })
321
322 // Navigate to a simple page to ensure the browser is ready
323 navInput := []byte(`{"url": "about:blank"}`)
324 toolOut := tools.NewNavigateTool().Run(ctx, navInput)
325 if toolOut.Error != nil {
326 if strings.Contains(toolOut.Error.Error(), "browser automation not available") {
327 t.Skip("Browser automation not available in this environment")
328 }
329 t.Fatalf("Navigation error: %v", toolOut.Error)
330 }
331 content := toolOut.LLMContent
332 if !strings.Contains(content[0].Text, "done") {
333 t.Fatalf("Expected done in navigation response, got: %s", content[0].Text)
334 }
335
336 // Check default viewport dimensions via JavaScript
337 evalInput := []byte(`{"expression": "({width: window.innerWidth, height: window.innerHeight})"}`)
338 toolOut = tools.NewEvalTool().Run(ctx, evalInput)
339 if toolOut.Error != nil {
340 t.Fatalf("Evaluation error: %v", toolOut.Error)
341 }
342 content = toolOut.LLMContent
343
344 // Parse the result to verify dimensions
345 var response struct {
346 Width float64 `json:"width"`
347 Height float64 `json:"height"`
348 }
349
350 text := content[0].Text
351 text = strings.TrimPrefix(text, "<javascript_result>")
352 text = strings.TrimSuffix(text, "</javascript_result>")
353
354 if err := json.Unmarshal([]byte(text), &response); err != nil {
355 t.Fatalf("Failed to parse evaluation response (%q => %q): %v", content[0].Text, text, err)
356 }
357
358 // Verify the default viewport size is 1280x720
359 expectedWidth := 1280.0
360 expectedHeight := 720.0
361
362 if response.Width != expectedWidth {
363 t.Errorf("Expected default width %v, got %v", expectedWidth, response.Width)
364 }
365 if response.Height != expectedHeight {
366 t.Errorf("Expected default height %v, got %v", expectedHeight, response.Height)
367 }
368}
369
370// TestBrowserIdleShutdownAndRestart verifies the browser shuts down after idle and can restart
371func TestBrowserIdleShutdownAndRestart(t *testing.T) {
372 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
373 defer cancel()
374
375 // Use a short idle timeout for testing
376 idleTimeout := 100 * time.Millisecond
377 tools := NewBrowseTools(ctx, idleTimeout, 0)
378 t.Cleanup(func() {
379 tools.Close()
380 })
381
382 // First use - should start the browser
383 browserCtx1, err := tools.GetBrowserContext()
384 if err != nil {
385 if strings.Contains(err.Error(), "failed to start browser") {
386 t.Skip("Browser automation not available in this environment")
387 }
388 t.Fatalf("Failed to get browser context: %v", err)
389 }
390 if browserCtx1 == nil {
391 t.Fatal("Expected non-nil browser context")
392 }
393
394 // Wait for idle timeout to fire
395 time.Sleep(idleTimeout + 50*time.Millisecond)
396
397 // Second use - should start a new browser (old one was killed)
398 browserCtx2, err := tools.GetBrowserContext()
399 if err != nil {
400 t.Fatalf("Failed to get browser context after idle: %v", err)
401 }
402 if browserCtx2 == nil {
403 t.Fatal("Expected non-nil browser context after restart")
404 }
405
406 // The contexts should be different (new browser instance)
407 if browserCtx1 == browserCtx2 {
408 t.Error("Expected different browser context after idle shutdown")
409 }
410
411 // Verify the new browser actually works
412 navTool := tools.NewNavigateTool()
413 input := []byte(`{"url": "about:blank"}`)
414 toolOut := navTool.Run(ctx, input)
415 if toolOut.Error != nil {
416 t.Fatalf("Navigate failed after restart: %v", toolOut.Error)
417 }
418}
419
420func TestReadImageToolResizesLargeImage(t *testing.T) {
421 // Create a test BrowseTools instance with max dimension of 2000
422 ctx := context.Background()
423 browseTools := NewBrowseTools(ctx, 0, 2000)
424 t.Cleanup(func() {
425 browseTools.Close()
426 })
427
428 // Create a large test image (3000x2500 pixels)
429 testDir := t.TempDir()
430 testImagePath := filepath.Join(testDir, "large_image.png")
431
432 // Create a large image using image package
433 img := image.NewRGBA(image.Rect(0, 0, 3000, 2500))
434 for y := 0; y < 2500; y++ {
435 for x := 0; x < 3000; x++ {
436 img.Set(x, y, color.RGBA{R: 100, G: 150, B: 200, A: 255})
437 }
438 }
439
440 f, err := os.Create(testImagePath)
441 if err != nil {
442 t.Fatalf("Failed to create test image file: %v", err)
443 }
444 if err := png.Encode(f, img); err != nil {
445 f.Close()
446 t.Fatalf("Failed to encode test image: %v", err)
447 }
448 f.Close()
449
450 // Create the tool
451 readImageTool := browseTools.NewReadImageTool()
452
453 // Prepare input
454 input := fmt.Sprintf(`{"path": "%s"}`, testImagePath)
455
456 // Run the tool
457 toolOut := readImageTool.Run(ctx, []byte(input))
458 if toolOut.Error != nil {
459 t.Fatalf("Read image tool failed: %v", toolOut.Error)
460 }
461 result := toolOut.LLMContent
462
463 // Check that we got at least two content objects
464 if len(result) < 2 {
465 t.Fatalf("Expected at least 2 content objects, got %d", len(result))
466 }
467
468 // Check that the description mentions resizing
469 if !strings.Contains(result[0].Text, "resized") {
470 t.Errorf("Expected description to mention resizing, got: %s", result[0].Text)
471 }
472
473 // Decode the returned image and verify dimensions are within limits
474 imageData, err := base64.StdEncoding.DecodeString(result[1].Data)
475 if err != nil {
476 t.Fatalf("Failed to decode base64 image: %v", err)
477 }
478
479 config, _, err := image.DecodeConfig(bytes.NewReader(imageData))
480 if err != nil {
481 t.Fatalf("Failed to decode image config: %v", err)
482 }
483
484 if config.Width > 2000 || config.Height > 2000 {
485 t.Errorf("Image dimensions still exceed 2000 pixels: %dx%d", config.Width, config.Height)
486 }
487
488 t.Logf("Large image resized from 3000x2500 to %dx%d", config.Width, config.Height)
489}
490
491// TestIsPort80 tests the isPort80 function
492func TestIsPort80(t *testing.T) {
493 tests := []struct {
494 url string
495 expected bool
496 name string
497 }{
498 {"http://example.com:80", true, "http with explicit port 80"},
499 {"http://example.com", true, "http without explicit port"},
500 {"https://example.com:80", true, "https with explicit port 80"},
501 {"http://example.com:8080", false, "http with different port"},
502 {"https://example.com", false, "https without explicit port"},
503 {"https://example.com:443", false, "https with standard port"},
504 {"invalid-url", false, "invalid URL"},
505 {"ftp://example.com:80", true, "ftp with port 80"},
506 }
507
508 for _, tt := range tests {
509 t.Run(tt.name, func(t *testing.T) {
510 result := isPort80(tt.url)
511 if result != tt.expected {
512 t.Errorf("isPort80(%q) = %v, want %v", tt.url, result, tt.expected)
513 }
514 })
515 }
516}
517
518// TestResizeRunErrorPaths tests error paths in resizeRun
519func TestResizeRunErrorPaths(t *testing.T) {
520 ctx := context.Background()
521 tools := NewBrowseTools(ctx, 0, 0)
522 t.Cleanup(func() {
523 tools.Close()
524 })
525
526 // Test with invalid JSON input
527 invalidInput := []byte(`{"width": "not-a-number"}`)
528 toolOut := tools.resizeRun(ctx, invalidInput)
529 if toolOut.Error == nil {
530 t.Error("No error expected for invalid JSON input in clearConsoleLogsRun")
531 }
532
533 // Test with negative dimensions
534 negativeInput := []byte(`{"width": -100, "height": 100}`)
535 toolOut = tools.resizeRun(ctx, negativeInput)
536 if toolOut.Error == nil {
537 t.Error("Expected error for negative width")
538 }
539
540 // Test with zero dimensions
541 zeroInput := []byte(`{"width": 0, "height": 100}`)
542 toolOut = tools.resizeRun(ctx, zeroInput)
543 if toolOut.Error == nil {
544 t.Error("Expected error for zero width")
545 }
546}
547
548// TestScreenshotRunErrorPaths tests error paths in screenshotRun
549func TestScreenshotRunErrorPaths(t *testing.T) {
550 ctx := context.Background()
551 tools := NewBrowseTools(ctx, 0, 0)
552 t.Cleanup(func() {
553 tools.Close()
554 })
555
556 // Test with invalid JSON input
557 invalidInput := []byte(`{"selector": 123}`)
558 toolOut := tools.screenshotRun(ctx, invalidInput)
559 if toolOut.Error == nil {
560 t.Error("No error expected for invalid JSON input in clearConsoleLogsRun")
561 }
562}
563
564func TestRecentConsoleLogsRunErrorPaths(t *testing.T) {
565 ctx := context.Background()
566 tools := NewBrowseTools(ctx, 0, 0)
567 t.Cleanup(func() {
568 tools.Close()
569 })
570
571 // Test with invalid JSON input
572 invalidInput := []byte(`{"limit": "not-a-number"}`)
573 toolOut := tools.recentConsoleLogsRun(ctx, invalidInput)
574 if toolOut.Error == nil {
575 t.Error("No error expected for invalid JSON input in clearConsoleLogsRun")
576 }
577}
578
579// TestParseTimeout tests the parseTimeout function
580func TestParseTimeout(t *testing.T) {
581 tests := []struct {
582 input string
583 expected time.Duration
584 name string
585 }{
586 {"10s", 10 * time.Second, "valid duration"},
587 {"5m", 5 * time.Minute, "valid minutes"},
588 {"", 15 * time.Second, "empty string defaults to 15s"},
589 {"invalid", 15 * time.Second, "invalid duration defaults to 15s"},
590 {"30ms", 30 * time.Millisecond, "valid milliseconds"},
591 }
592
593 for _, tt := range tests {
594 t.Run(tt.name, func(t *testing.T) {
595 result := parseTimeout(tt.input)
596 if result != tt.expected {
597 t.Errorf("parseTimeout(%q) = %v, want %v", tt.input, result, tt.expected)
598 }
599 })
600 }
601}
602
603// TestRegisterBrowserTools tests the RegisterBrowserTools function
604func TestRegisterBrowserTools(t *testing.T) {
605 ctx := context.Background()
606
607 // Test with screenshots enabled
608 tools, cleanup := RegisterBrowserTools(ctx, true, 0)
609 t.Cleanup(cleanup)
610
611 if len(tools) != 7 {
612 t.Errorf("Expected 7 tools with screenshots, got %d", len(tools))
613 }
614
615 // Test with screenshots disabled
616 tools, cleanup = RegisterBrowserTools(ctx, false, 0)
617 t.Cleanup(cleanup)
618
619 if len(tools) != 5 {
620 t.Errorf("Expected 5 tools without screenshots, got %d", len(tools))
621 }
622
623 // Verify that cleanup function works (doesn't panic)
624 cleanup()
625}
626
627// TestGetScreenshotPath tests the GetScreenshotPath function
628func TestGetScreenshotPath(t *testing.T) {
629 id := "test-id"
630 expected := filepath.Join(ScreenshotDir, id+".png")
631 actual := GetScreenshotPath(id)
632
633 if actual != expected {
634 t.Errorf("GetScreenshotPath(%q) = %q, want %q", id, actual, expected)
635 }
636}
637
638// TestSaveScreenshotErrorPath tests error paths in SaveScreenshot
639func TestSaveScreenshotErrorPath(t *testing.T) {
640 ctx := context.Background()
641 tools := NewBrowseTools(ctx, 0, 0)
642 t.Cleanup(func() {
643 tools.Close()
644 })
645
646 // Test with empty data (this should still work)
647 id := tools.SaveScreenshot([]byte{})
648 if id == "" {
649 t.Error("Expected non-empty ID for empty data")
650 }
651
652 // Clean up the test file
653 filePath := GetScreenshotPath(id)
654 os.Remove(filePath)
655}
656
657// TestConsoleLogsWriteToFile tests that large console logs are written to file
658func TestConsoleLogsWriteToFile(t *testing.T) {
659 ctx := context.Background()
660 tools := NewBrowseTools(ctx, 0, 0)
661 t.Cleanup(func() {
662 tools.Close()
663 })
664
665 // Manually add many console logs to exceed threshold
666 tools.consoleLogsMutex.Lock()
667 for i := 0; i < 50; i++ {
668 tools.consoleLogs = append(tools.consoleLogs, &runtime.EventConsoleAPICalled{
669 Type: runtime.APITypeLog,
670 Args: []*runtime.RemoteObject{
671 {Type: runtime.TypeString, Value: jsontext.Value(`"This is a long log message that will help exceed the 1KB threshold when repeated many times"`)},
672 },
673 })
674 }
675 tools.consoleLogsMutex.Unlock()
676
677 // Mock browser context to avoid actual browser initialization
678 tools.mux.Lock()
679 tools.browserCtx = ctx
680 tools.mux.Unlock()
681
682 // Get console logs - should be written to file
683 input := []byte(`{}`)
684 toolOut := tools.recentConsoleLogsRun(ctx, input)
685 if toolOut.Error != nil {
686 t.Fatalf("Unexpected error: %v", toolOut.Error)
687 }
688
689 resultText := toolOut.LLMContent[0].Text
690 if !strings.Contains(resultText, "Output written to:") {
691 t.Errorf("Expected output to be written to file, got: %s", resultText)
692 }
693 if !strings.Contains(resultText, ConsoleLogsDir) {
694 t.Errorf("Expected file path to contain %s, got: %s", ConsoleLogsDir, resultText)
695 }
696
697 // Extract file path and verify file exists
698 parts := strings.Split(resultText, "Output written to: ")
699 if len(parts) < 2 {
700 t.Fatalf("Could not extract file path from: %s", resultText)
701 }
702 filePath := strings.Split(parts[1], "\n")[0]
703 if _, err := os.Stat(filePath); os.IsNotExist(err) {
704 t.Errorf("Expected file to exist at %s", filePath)
705 } else {
706 // Clean up
707 os.Remove(filePath)
708 }
709}
710
711// TestGenerateDownloadFilename tests filename generation with randomness
712func TestGenerateDownloadFilename(t *testing.T) {
713 ctx := context.Background()
714 tools := NewBrowseTools(ctx, 0, 0)
715 t.Cleanup(func() {
716 tools.Close()
717 })
718
719 tests := []struct {
720 suggested string
721 prefix string
722 ext string
723 }{
724 {"test.txt", "test_", ".txt"},
725 {"document.pdf", "document_", ".pdf"},
726 {"noextension", "noextension_", ""},
727 {"", "download_", ""},
728 {"file.tar.gz", "file.tar_", ".gz"},
729 }
730
731 for _, tt := range tests {
732 t.Run(tt.suggested, func(t *testing.T) {
733 result := tools.generateDownloadFilename(tt.suggested)
734 if !strings.HasPrefix(result, tt.prefix) {
735 t.Errorf("Expected prefix %q, got %q", tt.prefix, result)
736 }
737 if !strings.HasSuffix(result, tt.ext) {
738 t.Errorf("Expected suffix %q, got %q", tt.ext, result)
739 }
740 // Verify randomness (8 chars between prefix and extension)
741 withoutPrefix := strings.TrimPrefix(result, tt.prefix)
742 withoutExt := strings.TrimSuffix(withoutPrefix, tt.ext)
743 if len(withoutExt) != 8 {
744 t.Errorf("Expected 8 random chars, got %d in %q", len(withoutExt), result)
745 }
746 })
747 }
748
749 // Verify different calls produce different results
750 result1 := tools.generateDownloadFilename("test.txt")
751 result2 := tools.generateDownloadFilename("test.txt")
752 if result1 == result2 {
753 t.Errorf("Expected different filenames, got same: %s", result1)
754 }
755}
756
757// TestDownloadTracking tests the download event handling
758func TestDownloadTracking(t *testing.T) {
759 ctx := context.Background()
760 tools := NewBrowseTools(ctx, 0, 0)
761 t.Cleanup(func() {
762 tools.Close()
763 })
764
765 // Simulate download start event
766 tools.handleDownloadWillBegin(&browser.EventDownloadWillBegin{
767 GUID: "test-guid-123",
768 URL: "http://example.com/file.txt",
769 SuggestedFilename: "file.txt",
770 })
771
772 // Verify download is tracked
773 tools.downloadsMutex.Lock()
774 info, exists := tools.downloads["test-guid-123"]
775 tools.downloadsMutex.Unlock()
776
777 if !exists {
778 t.Fatal("Expected download to be tracked")
779 }
780 if info.URL != "http://example.com/file.txt" {
781 t.Errorf("Expected URL %q, got %q", "http://example.com/file.txt", info.URL)
782 }
783 if info.Completed {
784 t.Error("Download should not be completed yet")
785 }
786
787 // Simulate download progress - canceled
788 tools.handleDownloadProgress(&browser.EventDownloadProgress{
789 GUID: "test-guid-123",
790 State: browser.DownloadProgressStateCanceled,
791 })
792
793 // Verify download is marked as completed with error
794 tools.downloadsMutex.Lock()
795 info = tools.downloads["test-guid-123"]
796 tools.downloadsMutex.Unlock()
797
798 if !info.Completed {
799 t.Error("Download should be completed after cancel")
800 }
801 if info.Error != "download canceled" {
802 t.Errorf("Expected error %q, got %q", "download canceled", info.Error)
803 }
804}
805
806// TestToolOutWithDownloads tests the download info appending to tool output
807func TestToolOutWithDownloads(t *testing.T) {
808 ctx := context.Background()
809 tools := NewBrowseTools(ctx, 0, 0)
810 t.Cleanup(func() {
811 tools.Close()
812 })
813
814 // Test with no downloads
815 out := tools.toolOutWithDownloads("test message")
816 if out.LLMContent[0].Text != "test message" {
817 t.Errorf("Expected %q, got %q", "test message", out.LLMContent[0].Text)
818 }
819
820 // Add a completed download
821 tools.downloadsMutex.Lock()
822 tools.downloads["guid1"] = &DownloadInfo{
823 GUID: "guid1",
824 URL: "http://example.com/files/test.txt",
825 SuggestedFilename: "test.txt",
826 FinalPath: "/tmp/test_abc123.txt",
827 Completed: true,
828 }
829 tools.downloadsMutex.Unlock()
830
831 // Test with downloads
832 out = tools.toolOutWithDownloads("done")
833 result := out.LLMContent[0].Text
834 if !strings.Contains(result, "Downloads completed:") {
835 t.Errorf("Expected downloads section, got: %s", result)
836 }
837 if !strings.Contains(result, "test.txt") {
838 t.Errorf("Expected filename in output, got: %s", result)
839 }
840 if !strings.Contains(result, "http://example.com/files/test.txt") {
841 t.Errorf("Expected URL in output, got: %s", result)
842 }
843 if !strings.Contains(result, "saved to:") {
844 t.Errorf("Expected 'saved to:' in output, got: %s", result)
845 }
846 if !strings.Contains(result, "/tmp/test_abc123.txt") {
847 t.Errorf("Expected final path in output, got: %s", result)
848 }
849
850 // Verify download was cleared after retrieval
851 tools.downloadsMutex.Lock()
852 _, exists := tools.downloads["guid1"]
853 tools.downloadsMutex.Unlock()
854 if exists {
855 t.Error("Expected download to be cleared after retrieval")
856 }
857}
858
859// TestBrowserDownload tests the full browser download workflow with a real HTTP server
860func TestBrowserDownload(t *testing.T) {
861 if testing.Short() {
862 t.Skip("skipping browser download test in short mode")
863 }
864
865 // Start a test HTTP server that triggers a download
866 listener, err := net.Listen("tcp", "127.0.0.1:0")
867 if err != nil {
868 t.Fatalf("Failed to start listener: %v", err)
869 }
870 port := listener.Addr().(*net.TCPAddr).Port
871
872 mux := http.NewServeMux()
873 mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
874 w.Header().Set("Content-Disposition", "attachment; filename=\"test.txt\"")
875 w.Header().Set("Content-Type", "text/plain")
876 w.Write([]byte("Hello, this is a test file!"))
877 })
878 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
879 w.Header().Set("Content-Type", "text/html")
880 w.Write([]byte(fmt.Sprintf(`<!DOCTYPE html>
881<html>
882<body>
883<a id="download-link" href="/download">Download</a>
884</body>
885</html>`)))
886 })
887
888 server := &http.Server{Handler: mux}
889 go server.Serve(listener)
890 defer server.Close()
891
892 // Create browser tools
893 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
894 defer cancel()
895
896 tools := NewBrowseTools(ctx, 0, 0)
897 t.Cleanup(func() {
898 tools.Close()
899 })
900
901 // Navigate to the test page
902 navInput := []byte(fmt.Sprintf(`{"url": "http://127.0.0.1:%d/"}`, port))
903 toolOut := tools.NewNavigateTool().Run(ctx, navInput)
904 if toolOut.Error != nil {
905 if strings.Contains(toolOut.Error.Error(), "failed to start browser") {
906 t.Skip("Browser automation not available in this environment")
907 }
908 t.Fatalf("Navigation error: %v", toolOut.Error)
909 }
910
911 // Click the download link
912 evalInput := []byte(`{"expression": "document.getElementById('download-link').click()"}`)
913 toolOut = tools.NewEvalTool().Run(ctx, evalInput)
914 if toolOut.Error != nil {
915 t.Fatalf("Eval error: %v", toolOut.Error)
916 }
917
918 // Wait for download to complete (poll for completion)
919 var downloadFound bool
920 for i := 0; i < 20; i++ {
921 time.Sleep(100 * time.Millisecond)
922 files, err := os.ReadDir(DownloadDir)
923 if err != nil {
924 continue
925 }
926 for _, f := range files {
927 // Check for renamed file (test_*) or GUID file
928 if strings.HasPrefix(f.Name(), "test_") || len(f.Name()) == 36 {
929 filePath := filepath.Join(DownloadDir, f.Name())
930 content, err := os.ReadFile(filePath)
931 if err == nil && string(content) == "Hello, this is a test file!" {
932 downloadFound = true
933 t.Logf("Found downloaded file: %s", f.Name())
934 // Clean up
935 os.Remove(filePath)
936 break
937 }
938 }
939 }
940 if downloadFound {
941 break
942 }
943 }
944
945 if !downloadFound {
946 // List what's in the directory for debugging
947 files, _ := os.ReadDir(DownloadDir)
948 var names []string
949 for _, f := range files {
950 names = append(names, f.Name())
951 }
952 t.Errorf("Download file not found. Files in %s: %v", DownloadDir, names)
953 }
954}
955
956// TestBrowserDownloadReported tests that downloads are reported in tool output
957func TestBrowserDownloadReported(t *testing.T) {
958 if testing.Short() {
959 t.Skip("skipping browser download test in short mode")
960 }
961
962 // Start a test HTTP server that triggers a download
963 listener, err := net.Listen("tcp", "127.0.0.1:0")
964 if err != nil {
965 t.Fatalf("Failed to start listener: %v", err)
966 }
967 port := listener.Addr().(*net.TCPAddr).Port
968
969 mux := http.NewServeMux()
970 mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
971 w.Header().Set("Content-Disposition", "attachment; filename=\"report_test.txt\"")
972 w.Header().Set("Content-Type", "text/plain")
973 w.Write([]byte("Download report test file content"))
974 })
975
976 server := &http.Server{Handler: mux}
977 go server.Serve(listener)
978 defer server.Close()
979
980 // Create browser tools
981 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
982 defer cancel()
983
984 tools := NewBrowseTools(ctx, 0, 0)
985 t.Cleanup(func() {
986 tools.Close()
987 })
988
989 // Navigate directly to the download URL - should succeed with download info
990 navInput := []byte(fmt.Sprintf(`{"url": "http://127.0.0.1:%d/download"}`, port))
991 toolOut := tools.NewNavigateTool().Run(ctx, navInput)
992 if toolOut.Error != nil {
993 if strings.Contains(toolOut.Error.Error(), "failed to start browser") {
994 t.Skip("Browser automation not available in this environment")
995 }
996 t.Fatalf("Navigation returned unexpected error: %v", toolOut.Error)
997 }
998
999 result := toolOut.LLMContent[0].Text
1000 t.Logf("Navigation result: %s", result)
1001
1002 // Navigation to download URL should report the download directly
1003 if !strings.Contains(result, "download") {
1004 t.Errorf("Expected 'download' in output, got: %s", result)
1005 }
1006 if !strings.Contains(result, "report_test") {
1007 t.Errorf("Expected 'report_test' in download output, got: %s", result)
1008 }
1009 if !strings.Contains(result, DownloadDir) {
1010 t.Errorf("Expected download path, got: %s", result)
1011 }
1012
1013 // Clean up any downloaded files
1014 files, _ := os.ReadDir(DownloadDir)
1015 for _, f := range files {
1016 if strings.HasPrefix(f.Name(), "report_test_") {
1017 os.Remove(filepath.Join(DownloadDir, f.Name()))
1018 }
1019 }
1020}
1021
1022// TestLargeJSOutputWriteToFile tests that large JS eval results are written to file
1023func TestLargeJSOutputWriteToFile(t *testing.T) {
1024 if testing.Short() {
1025 t.Skip("skipping browser test in short mode")
1026 }
1027
1028 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
1029 defer cancel()
1030
1031 tools := NewBrowseTools(ctx, 0, 0)
1032 t.Cleanup(func() {
1033 tools.Close()
1034 })
1035
1036 // Navigate to about:blank first
1037 navInput := []byte(`{"url": "about:blank"}`)
1038 toolOut := tools.NewNavigateTool().Run(ctx, navInput)
1039 if toolOut.Error != nil {
1040 if strings.Contains(toolOut.Error.Error(), "failed to start browser") {
1041 t.Skip("Browser automation not available in this environment")
1042 }
1043 t.Fatalf("Navigation error: %v", toolOut.Error)
1044 }
1045
1046 // Execute JS that returns a large string (> 1KB)
1047 evalInput := []byte(`{"expression": "'x'.repeat(2000)"}`)
1048 toolOut = tools.NewEvalTool().Run(ctx, evalInput)
1049 if toolOut.Error != nil {
1050 t.Fatalf("Eval error: %v", toolOut.Error)
1051 }
1052
1053 result := toolOut.LLMContent[0].Text
1054 t.Logf("Result: %s", result[:min(200, len(result))])
1055
1056 // Should be written to file
1057 if !strings.Contains(result, "JavaScript result") {
1058 t.Errorf("Expected 'JavaScript result' in output, got: %s", result)
1059 }
1060 if !strings.Contains(result, "written to:") {
1061 t.Errorf("Expected 'written to:' in output, got: %s", result)
1062 }
1063 if !strings.Contains(result, ConsoleLogsDir) {
1064 t.Errorf("Expected file path to contain %s, got: %s", ConsoleLogsDir, result)
1065 }
1066
1067 // Extract and verify file exists
1068 parts := strings.Split(result, "written to: ")
1069 if len(parts) >= 2 {
1070 filePath := strings.Split(parts[1], "\n")[0]
1071 if _, err := os.Stat(filePath); os.IsNotExist(err) {
1072 t.Errorf("Expected file to exist at %s", filePath)
1073 } else {
1074 // Verify content
1075 content, err := os.ReadFile(filePath)
1076 if err != nil {
1077 t.Errorf("Failed to read file: %v", err)
1078 } else if len(content) < 2000 {
1079 t.Errorf("Expected file to contain large result, got %d bytes", len(content))
1080 }
1081 // Clean up
1082 os.Remove(filePath)
1083 }
1084 }
1085}
1086
1087// TestSmallJSOutputInline tests that small JS results are returned inline
1088func TestSmallJSOutputInline(t *testing.T) {
1089 if testing.Short() {
1090 t.Skip("skipping browser test in short mode")
1091 }
1092
1093 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
1094 defer cancel()
1095
1096 tools := NewBrowseTools(ctx, 0, 0)
1097 t.Cleanup(func() {
1098 tools.Close()
1099 })
1100
1101 // Navigate to about:blank first
1102 navInput := []byte(`{"url": "about:blank"}`)
1103 toolOut := tools.NewNavigateTool().Run(ctx, navInput)
1104 if toolOut.Error != nil {
1105 if strings.Contains(toolOut.Error.Error(), "failed to start browser") {
1106 t.Skip("Browser automation not available in this environment")
1107 }
1108 t.Fatalf("Navigation error: %v", toolOut.Error)
1109 }
1110
1111 // Execute JS that returns a small string (< 1KB)
1112 evalInput := []byte(`{"expression": "'hello world'"}`)
1113 toolOut = tools.NewEvalTool().Run(ctx, evalInput)
1114 if toolOut.Error != nil {
1115 t.Fatalf("Eval error: %v", toolOut.Error)
1116 }
1117
1118 result := toolOut.LLMContent[0].Text
1119
1120 // Should be inline
1121 if !strings.Contains(result, "<javascript_result>") {
1122 t.Errorf("Expected '<javascript_result>' in output, got: %s", result)
1123 }
1124 if !strings.Contains(result, "hello world") {
1125 t.Errorf("Expected 'hello world' in output, got: %s", result)
1126 }
1127 if strings.Contains(result, "written to:") {
1128 t.Errorf("Small result should not be written to file, got: %s", result)
1129 }
1130}