| package browse |
| |
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "os" |
| "path/filepath" |
| "slices" |
| "strings" |
| "testing" |
| "time" |
| |
| "github.com/chromedp/chromedp" |
| "sketch.dev/llm" |
| ) |
| |
| func TestToolCreation(t *testing.T) { |
| // Create browser tools instance |
| tools := NewBrowseTools(context.Background()) |
| t.Cleanup(func() { |
| tools.Close() |
| }) |
| |
| // Test each tool has correct name and description |
| toolTests := []struct { |
| tool *llm.Tool |
| expectedName string |
| shortDesc string |
| requiredProps []string |
| }{ |
| {tools.NewNavigateTool(), "browser_navigate", "Navigate", []string{"url"}}, |
| {tools.NewEvalTool(), "browser_eval", "Evaluate", []string{"expression"}}, |
| {tools.NewScreenshotTool(), "browser_take_screenshot", "Take", nil}, |
| } |
| |
| for _, tt := range toolTests { |
| t.Run(tt.expectedName, func(t *testing.T) { |
| if tt.tool.Name != tt.expectedName { |
| t.Errorf("expected name %q, got %q", tt.expectedName, tt.tool.Name) |
| } |
| |
| if !strings.Contains(tt.tool.Description, tt.shortDesc) { |
| t.Errorf("description %q should contain %q", tt.tool.Description, tt.shortDesc) |
| } |
| |
| // Verify schema has required properties |
| if len(tt.requiredProps) > 0 { |
| var schema struct { |
| Required []string `json:"required"` |
| } |
| if err := json.Unmarshal(tt.tool.InputSchema, &schema); err != nil { |
| t.Fatalf("failed to unmarshal schema: %v", err) |
| } |
| |
| for _, prop := range tt.requiredProps { |
| if !slices.Contains(schema.Required, prop) { |
| t.Errorf("property %q should be required", prop) |
| } |
| } |
| } |
| }) |
| } |
| } |
| |
| func TestGetTools(t *testing.T) { |
| // Create browser tools instance |
| tools := NewBrowseTools(context.Background()) |
| t.Cleanup(func() { |
| tools.Close() |
| }) |
| |
| // Test with screenshot tools included |
| t.Run("with screenshots", func(t *testing.T) { |
| toolsWithScreenshots := tools.GetTools(true) |
| if len(toolsWithScreenshots) != 6 { |
| t.Errorf("expected 6 tools with screenshots, got %d", len(toolsWithScreenshots)) |
| } |
| |
| // Check tool naming convention |
| for _, tool := range toolsWithScreenshots { |
| // Most tools have browser_ prefix, except for read_image |
| if tool.Name != "read_image" && !strings.HasPrefix(tool.Name, "browser_") { |
| t.Errorf("tool name %q does not have prefix 'browser_'", tool.Name) |
| } |
| } |
| }) |
| |
| // Test without screenshot tools |
| t.Run("without screenshots", func(t *testing.T) { |
| noScreenshotTools := tools.GetTools(false) |
| if len(noScreenshotTools) != 4 { |
| t.Errorf("expected 4 tools without screenshots, got %d", len(noScreenshotTools)) |
| } |
| }) |
| } |
| |
| // TestBrowserInitialization verifies that the browser can start correctly |
| func TestBrowserInitialization(t *testing.T) { |
| // Skip long tests in short mode |
| if testing.Short() { |
| t.Skip("skipping browser initialization test in short mode") |
| } |
| |
| // Create browser tools instance |
| ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| defer cancel() |
| |
| tools := NewBrowseTools(ctx) |
| t.Cleanup(func() { |
| tools.Close() |
| }) |
| |
| // Initialize the browser |
| err := tools.Initialize() |
| if err != nil { |
| // If browser automation is not available, skip the test |
| if strings.Contains(err.Error(), "browser automation not available") { |
| t.Skip("Browser automation not available in this environment") |
| } else { |
| t.Fatalf("Failed to initialize browser: %v", err) |
| } |
| } |
| |
| // Get browser context to verify it's working |
| browserCtx, err := tools.GetBrowserContext() |
| if err != nil { |
| t.Fatalf("Failed to get browser context: %v", err) |
| } |
| |
| // Try to navigate to a simple page |
| var title string |
| err = chromedp.Run(browserCtx, |
| chromedp.Navigate("about:blank"), |
| chromedp.Title(&title), |
| ) |
| if err != nil { |
| t.Fatalf("Failed to navigate to about:blank: %v", err) |
| } |
| |
| t.Logf("Successfully navigated to about:blank, title: %q", title) |
| } |
| |
| // TestNavigateTool verifies that the navigate tool works correctly |
| func TestNavigateTool(t *testing.T) { |
| // Skip long tests in short mode |
| if testing.Short() { |
| t.Skip("skipping navigate tool test in short mode") |
| } |
| |
| // Create browser tools instance |
| ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| defer cancel() |
| |
| tools := NewBrowseTools(ctx) |
| t.Cleanup(func() { |
| tools.Close() |
| }) |
| |
| // Check if browser initialization works |
| if err := tools.Initialize(); err != nil { |
| if strings.Contains(err.Error(), "browser automation not available") { |
| t.Skip("Browser automation not available in this environment") |
| } |
| } |
| |
| // Get the navigate tool |
| navTool := tools.NewNavigateTool() |
| |
| // Create input for the navigate tool |
| input := map[string]string{"url": "https://example.com"} |
| inputJSON, _ := json.Marshal(input) |
| |
| // Call the tool |
| toolOut := navTool.Run(ctx, json.RawMessage(inputJSON)) |
| if toolOut.Error != nil { |
| t.Fatalf("Error running navigate tool: %v", toolOut.Error) |
| } |
| result := toolOut.LLMContent |
| |
| // Verify the response is successful |
| resultText := result[0].Text |
| if !strings.Contains(resultText, "done") { |
| // If browser automation is not available, skip the test |
| if strings.Contains(resultText, "browser automation not available") { |
| t.Skip("Browser automation not available in this environment") |
| } else { |
| t.Fatalf("Expected done in result text, got: %s", resultText) |
| } |
| } |
| |
| // Try to get the page title to verify the navigation worked |
| browserCtx, err := tools.GetBrowserContext() |
| if err != nil { |
| // If browser automation is not available, skip the test |
| if strings.Contains(err.Error(), "browser automation not available") { |
| t.Skip("Browser automation not available in this environment") |
| } else { |
| t.Fatalf("Failed to get browser context: %v", err) |
| } |
| } |
| |
| var title string |
| err = chromedp.Run(browserCtx, chromedp.Title(&title)) |
| if err != nil { |
| t.Fatalf("Failed to get page title: %v", err) |
| } |
| |
| t.Logf("Successfully navigated to example.com, title: %q", title) |
| if title != "Example Domain" { |
| t.Errorf("Expected title 'Example Domain', got '%s'", title) |
| } |
| } |
| |
| // TestScreenshotTool tests that the screenshot tool properly saves files |
| func TestScreenshotTool(t *testing.T) { |
| // Create browser tools instance |
| ctx := context.Background() |
| tools := NewBrowseTools(ctx) |
| t.Cleanup(func() { |
| tools.Close() |
| }) |
| |
| // Test SaveScreenshot function directly |
| testData := []byte("test image data") |
| id := tools.SaveScreenshot(testData) |
| if id == "" { |
| t.Fatal("SaveScreenshot returned empty ID") |
| } |
| |
| // Get the file path and check if the file exists |
| filePath := GetScreenshotPath(id) |
| _, err := os.Stat(filePath) |
| if err != nil { |
| t.Fatalf("Failed to find screenshot file: %v", err) |
| } |
| |
| // Read the file contents |
| contents, err := os.ReadFile(filePath) |
| if err != nil { |
| t.Fatalf("Failed to read screenshot file: %v", err) |
| } |
| |
| // Check the file contents |
| if string(contents) != string(testData) { |
| t.Errorf("File contents don't match: expected %q, got %q", string(testData), string(contents)) |
| } |
| |
| // Clean up the test file |
| os.Remove(filePath) |
| } |
| |
| func TestReadImageTool(t *testing.T) { |
| // Create a test BrowseTools instance |
| ctx := context.Background() |
| browseTools := NewBrowseTools(ctx) |
| t.Cleanup(func() { |
| browseTools.Close() |
| }) |
| |
| // Create a test image |
| testDir := t.TempDir() |
| testImagePath := filepath.Join(testDir, "test_image.png") |
| |
| // Create a small 1x1 black PNG image |
| smallPng := []byte{ |
| 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, |
| 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, |
| 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0x60, 0x00, 0x00, 0x00, |
| 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, |
| 0x42, 0x60, 0x82, |
| } |
| |
| // Write the test image |
| err := os.WriteFile(testImagePath, smallPng, 0o644) |
| if err != nil { |
| t.Fatalf("Failed to create test image: %v", err) |
| } |
| |
| // Create the tool |
| readImageTool := browseTools.NewReadImageTool() |
| |
| // Prepare input |
| input := fmt.Sprintf(`{"path": "%s"}`, testImagePath) |
| |
| // Run the tool |
| toolOut := readImageTool.Run(ctx, json.RawMessage(input)) |
| if toolOut.Error != nil { |
| t.Fatalf("Read image tool failed: %v", toolOut.Error) |
| } |
| result := toolOut.LLMContent |
| |
| // In the updated code, result is already a []llm.Content |
| contents := result |
| |
| // Check that we got at least two content objects |
| if len(contents) < 2 { |
| t.Fatalf("Expected at least 2 content objects, got %d", len(contents)) |
| } |
| |
| // Check that the second content has image data |
| if contents[1].MediaType == "" { |
| t.Errorf("Expected MediaType in second content") |
| } |
| |
| if contents[1].Data == "" { |
| t.Errorf("Expected Data in second content") |
| } |
| } |
| |
| // TestDefaultViewportSize verifies that the browser starts with the correct default viewport size |
| func TestDefaultViewportSize(t *testing.T) { |
| ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| defer cancel() |
| |
| // Skip if CI or headless testing environment |
| if os.Getenv("CI") != "" || os.Getenv("HEADLESS_TEST") != "" { |
| t.Skip("Skipping browser test in CI/headless environment") |
| } |
| |
| tools := NewBrowseTools(ctx) |
| t.Cleanup(func() { |
| tools.Close() |
| }) |
| |
| // Initialize browser (which should set default viewport to 1280x720) |
| err := tools.Initialize() |
| if err != nil { |
| if strings.Contains(err.Error(), "browser automation not available") { |
| t.Skip("Browser automation not available in this environment") |
| } else { |
| t.Fatalf("Failed to initialize browser: %v", err) |
| } |
| } |
| |
| // Navigate to a simple page to ensure the browser is ready |
| navInput := json.RawMessage(`{"url": "about:blank"}`) |
| toolOut := tools.NewNavigateTool().Run(ctx, navInput) |
| if toolOut.Error != nil { |
| t.Fatalf("Navigation error: %v", toolOut.Error) |
| } |
| content := toolOut.LLMContent |
| if !strings.Contains(content[0].Text, "done") { |
| t.Fatalf("Expected done in navigation response, got: %s", content[0].Text) |
| } |
| |
| // Check default viewport dimensions via JavaScript |
| evalInput := json.RawMessage(`{"expression": "({width: window.innerWidth, height: window.innerHeight})"}`) |
| toolOut = tools.NewEvalTool().Run(ctx, evalInput) |
| if toolOut.Error != nil { |
| t.Fatalf("Evaluation error: %v", toolOut.Error) |
| } |
| content = toolOut.LLMContent |
| |
| // Parse the result to verify dimensions |
| var response struct { |
| Width float64 `json:"width"` |
| Height float64 `json:"height"` |
| } |
| |
| text := content[0].Text |
| text = strings.TrimPrefix(text, "<javascript_result>") |
| text = strings.TrimSuffix(text, "</javascript_result>") |
| |
| if err := json.Unmarshal([]byte(text), &response); err != nil { |
| t.Fatalf("Failed to parse evaluation response (%q => %q): %v", content[0].Text, text, err) |
| } |
| |
| // Verify the default viewport size is 1280x720 |
| expectedWidth := 1280.0 |
| expectedHeight := 720.0 |
| |
| if response.Width != expectedWidth { |
| t.Errorf("Expected default width %v, got %v", expectedWidth, response.Width) |
| } |
| if response.Height != expectedHeight { |
| t.Errorf("Expected default height %v, got %v", expectedHeight, response.Height) |
| } |
| } |