browse_test.go

   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}