blob: def464bda1571fff321ec59a067ea87764f0284a [file] [log] [blame]
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001package browse
2
3import (
4 "context"
5 "encoding/json"
Philip Zeyliger72252cb2025-05-10 17:00:08 -07006 "fmt"
Philip Zeyliger33d282f2025-05-03 04:01:54 +00007 "os"
Philip Zeyliger72252cb2025-05-10 17:00:08 -07008 "path/filepath"
Philip Zeyliger33d282f2025-05-03 04:01:54 +00009 "slices"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/chromedp/chromedp"
15 "sketch.dev/llm"
16)
17
18func TestToolCreation(t *testing.T) {
19 // Create browser tools instance
20 tools := NewBrowseTools(context.Background())
Philip Zeyligera35de5f2025-06-14 12:00:48 -070021 t.Cleanup(func() {
22 tools.Close()
23 })
Philip Zeyliger33d282f2025-05-03 04:01:54 +000024
25 // Test each tool has correct name and description
26 toolTests := []struct {
27 tool *llm.Tool
28 expectedName string
29 shortDesc string
30 requiredProps []string
31 }{
32 {tools.NewNavigateTool(), "browser_navigate", "Navigate", []string{"url"}},
Philip Zeyliger33d282f2025-05-03 04:01:54 +000033 {tools.NewEvalTool(), "browser_eval", "Evaluate", []string{"expression"}},
Philip Zeyliger80b488d2025-05-10 18:21:54 -070034 {tools.NewScreenshotTool(), "browser_take_screenshot", "Take", nil},
Philip Zeyliger33d282f2025-05-03 04:01:54 +000035 }
36
37 for _, tt := range toolTests {
38 t.Run(tt.expectedName, func(t *testing.T) {
39 if tt.tool.Name != tt.expectedName {
40 t.Errorf("expected name %q, got %q", tt.expectedName, tt.tool.Name)
41 }
42
43 if !strings.Contains(tt.tool.Description, tt.shortDesc) {
44 t.Errorf("description %q should contain %q", tt.tool.Description, tt.shortDesc)
45 }
46
47 // Verify schema has required properties
48 if len(tt.requiredProps) > 0 {
49 var schema struct {
50 Required []string `json:"required"`
51 }
52 if err := json.Unmarshal(tt.tool.InputSchema, &schema); err != nil {
53 t.Fatalf("failed to unmarshal schema: %v", err)
54 }
55
56 for _, prop := range tt.requiredProps {
57 if !slices.Contains(schema.Required, prop) {
58 t.Errorf("property %q should be required", prop)
59 }
60 }
61 }
62 })
63 }
64}
65
Philip Zeyliger72252cb2025-05-10 17:00:08 -070066func TestGetTools(t *testing.T) {
Philip Zeyliger33d282f2025-05-03 04:01:54 +000067 // Create browser tools instance
68 tools := NewBrowseTools(context.Background())
Philip Zeyligera35de5f2025-06-14 12:00:48 -070069 t.Cleanup(func() {
70 tools.Close()
71 })
Philip Zeyliger33d282f2025-05-03 04:01:54 +000072
Philip Zeyliger72252cb2025-05-10 17:00:08 -070073 // Test with screenshot tools included
74 t.Run("with screenshots", func(t *testing.T) {
75 toolsWithScreenshots := tools.GetTools(true)
Josh Bleecher Snydera271a212025-07-30 23:08:00 +000076 if len(toolsWithScreenshots) != 6 {
77 t.Errorf("expected 6 tools with screenshots, got %d", len(toolsWithScreenshots))
Philip Zeyliger33d282f2025-05-03 04:01:54 +000078 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -070079
80 // Check tool naming convention
81 for _, tool := range toolsWithScreenshots {
Philip Zeyliger542bda32025-06-11 18:31:03 -070082 // Most tools have browser_ prefix, except for read_image
83 if tool.Name != "read_image" && !strings.HasPrefix(tool.Name, "browser_") {
Philip Zeyliger72252cb2025-05-10 17:00:08 -070084 t.Errorf("tool name %q does not have prefix 'browser_'", tool.Name)
85 }
86 }
87 })
88
89 // Test without screenshot tools
90 t.Run("without screenshots", func(t *testing.T) {
91 noScreenshotTools := tools.GetTools(false)
Josh Bleecher Snydera271a212025-07-30 23:08:00 +000092 if len(noScreenshotTools) != 4 {
93 t.Errorf("expected 4 tools without screenshots, got %d", len(noScreenshotTools))
Philip Zeyliger72252cb2025-05-10 17:00:08 -070094 }
95 })
Philip Zeyliger33d282f2025-05-03 04:01:54 +000096}
97
98// TestBrowserInitialization verifies that the browser can start correctly
99func TestBrowserInitialization(t *testing.T) {
100 // Skip long tests in short mode
101 if testing.Short() {
102 t.Skip("skipping browser initialization test in short mode")
103 }
104
105 // Create browser tools instance
Philip Zeyliger29c481c2025-06-15 21:25:45 -0700106 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000107 defer cancel()
108
109 tools := NewBrowseTools(ctx)
Philip Zeyligera35de5f2025-06-14 12:00:48 -0700110 t.Cleanup(func() {
111 tools.Close()
112 })
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000113
114 // Initialize the browser
115 err := tools.Initialize()
116 if err != nil {
117 // If browser automation is not available, skip the test
118 if strings.Contains(err.Error(), "browser automation not available") {
119 t.Skip("Browser automation not available in this environment")
120 } else {
121 t.Fatalf("Failed to initialize browser: %v", err)
122 }
123 }
124
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000125 // Get browser context to verify it's working
126 browserCtx, err := tools.GetBrowserContext()
127 if err != nil {
128 t.Fatalf("Failed to get browser context: %v", err)
129 }
130
131 // Try to navigate to a simple page
132 var title string
133 err = chromedp.Run(browserCtx,
134 chromedp.Navigate("about:blank"),
135 chromedp.Title(&title),
136 )
137 if err != nil {
138 t.Fatalf("Failed to navigate to about:blank: %v", err)
139 }
140
141 t.Logf("Successfully navigated to about:blank, title: %q", title)
142}
143
144// TestNavigateTool verifies that the navigate tool works correctly
145func TestNavigateTool(t *testing.T) {
146 // Skip long tests in short mode
147 if testing.Short() {
148 t.Skip("skipping navigate tool test in short mode")
149 }
150
151 // Create browser tools instance
Philip Zeyliger29c481c2025-06-15 21:25:45 -0700152 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000153 defer cancel()
154
155 tools := NewBrowseTools(ctx)
Philip Zeyligera35de5f2025-06-14 12:00:48 -0700156 t.Cleanup(func() {
157 tools.Close()
158 })
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000159
160 // Check if browser initialization works
161 if err := tools.Initialize(); err != nil {
162 if strings.Contains(err.Error(), "browser automation not available") {
163 t.Skip("Browser automation not available in this environment")
164 }
165 }
166
167 // Get the navigate tool
168 navTool := tools.NewNavigateTool()
169
170 // Create input for the navigate tool
171 input := map[string]string{"url": "https://example.com"}
172 inputJSON, _ := json.Marshal(input)
173
174 // Call the tool
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700175 toolOut := navTool.Run(ctx, json.RawMessage(inputJSON))
176 if toolOut.Error != nil {
177 t.Fatalf("Error running navigate tool: %v", toolOut.Error)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000178 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700179 result := toolOut.LLMContent
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000180
181 // Verify the response is successful
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700182 resultText := result[0].Text
Josh Bleecher Snydercb557262025-06-30 23:55:20 +0000183 if !strings.Contains(resultText, "done") {
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000184 // If browser automation is not available, skip the test
Josh Bleecher Snydercb557262025-06-30 23:55:20 +0000185 if strings.Contains(resultText, "browser automation not available") {
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000186 t.Skip("Browser automation not available in this environment")
187 } else {
Josh Bleecher Snydercb557262025-06-30 23:55:20 +0000188 t.Fatalf("Expected done in result text, got: %s", resultText)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000189 }
190 }
191
192 // Try to get the page title to verify the navigation worked
193 browserCtx, err := tools.GetBrowserContext()
194 if err != nil {
195 // If browser automation is not available, skip the test
196 if strings.Contains(err.Error(), "browser automation not available") {
197 t.Skip("Browser automation not available in this environment")
198 } else {
199 t.Fatalf("Failed to get browser context: %v", err)
200 }
201 }
202
203 var title string
204 err = chromedp.Run(browserCtx, chromedp.Title(&title))
205 if err != nil {
206 t.Fatalf("Failed to get page title: %v", err)
207 }
208
209 t.Logf("Successfully navigated to example.com, title: %q", title)
210 if title != "Example Domain" {
211 t.Errorf("Expected title 'Example Domain', got '%s'", title)
212 }
213}
214
215// TestScreenshotTool tests that the screenshot tool properly saves files
216func TestScreenshotTool(t *testing.T) {
217 // Create browser tools instance
218 ctx := context.Background()
219 tools := NewBrowseTools(ctx)
Philip Zeyligera35de5f2025-06-14 12:00:48 -0700220 t.Cleanup(func() {
221 tools.Close()
222 })
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000223
224 // Test SaveScreenshot function directly
225 testData := []byte("test image data")
226 id := tools.SaveScreenshot(testData)
227 if id == "" {
228 t.Fatal("SaveScreenshot returned empty ID")
229 }
230
231 // Get the file path and check if the file exists
232 filePath := GetScreenshotPath(id)
233 _, err := os.Stat(filePath)
234 if err != nil {
235 t.Fatalf("Failed to find screenshot file: %v", err)
236 }
237
238 // Read the file contents
239 contents, err := os.ReadFile(filePath)
240 if err != nil {
241 t.Fatalf("Failed to read screenshot file: %v", err)
242 }
243
244 // Check the file contents
245 if string(contents) != string(testData) {
246 t.Errorf("File contents don't match: expected %q, got %q", string(testData), string(contents))
247 }
248
249 // Clean up the test file
250 os.Remove(filePath)
251}
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700252
253func TestReadImageTool(t *testing.T) {
254 // Create a test BrowseTools instance
255 ctx := context.Background()
256 browseTools := NewBrowseTools(ctx)
Philip Zeyligera35de5f2025-06-14 12:00:48 -0700257 t.Cleanup(func() {
258 browseTools.Close()
259 })
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700260
261 // Create a test image
262 testDir := t.TempDir()
263 testImagePath := filepath.Join(testDir, "test_image.png")
264
265 // Create a small 1x1 black PNG image
266 smallPng := []byte{
267 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
268 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
269 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0x60, 0x00, 0x00, 0x00,
270 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
271 0x42, 0x60, 0x82,
272 }
273
274 // Write the test image
275 err := os.WriteFile(testImagePath, smallPng, 0o644)
276 if err != nil {
277 t.Fatalf("Failed to create test image: %v", err)
278 }
279
280 // Create the tool
281 readImageTool := browseTools.NewReadImageTool()
282
283 // Prepare input
284 input := fmt.Sprintf(`{"path": "%s"}`, testImagePath)
285
286 // Run the tool
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700287 toolOut := readImageTool.Run(ctx, json.RawMessage(input))
288 if toolOut.Error != nil {
289 t.Fatalf("Read image tool failed: %v", toolOut.Error)
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700290 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700291 result := toolOut.LLMContent
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700292
293 // In the updated code, result is already a []llm.Content
294 contents := result
295
296 // Check that we got at least two content objects
297 if len(contents) < 2 {
298 t.Fatalf("Expected at least 2 content objects, got %d", len(contents))
299 }
300
301 // Check that the second content has image data
302 if contents[1].MediaType == "" {
303 t.Errorf("Expected MediaType in second content")
304 }
305
306 if contents[1].Data == "" {
307 t.Errorf("Expected Data in second content")
308 }
309}
Philip Zeyliger05224842025-05-10 18:26:08 -0700310
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000311// TestDefaultViewportSize verifies that the browser starts with the correct default viewport size
312func TestDefaultViewportSize(t *testing.T) {
Philip Zeyliger29c481c2025-06-15 21:25:45 -0700313 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000314 defer cancel()
315
316 // Skip if CI or headless testing environment
317 if os.Getenv("CI") != "" || os.Getenv("HEADLESS_TEST") != "" {
318 t.Skip("Skipping browser test in CI/headless environment")
319 }
320
321 tools := NewBrowseTools(ctx)
Philip Zeyligera35de5f2025-06-14 12:00:48 -0700322 t.Cleanup(func() {
323 tools.Close()
324 })
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000325
326 // Initialize browser (which should set default viewport to 1280x720)
327 err := tools.Initialize()
328 if err != nil {
329 if strings.Contains(err.Error(), "browser automation not available") {
330 t.Skip("Browser automation not available in this environment")
331 } else {
332 t.Fatalf("Failed to initialize browser: %v", err)
333 }
334 }
335
336 // Navigate to a simple page to ensure the browser is ready
337 navInput := json.RawMessage(`{"url": "about:blank"}`)
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700338 toolOut := tools.NewNavigateTool().Run(ctx, navInput)
339 if toolOut.Error != nil {
340 t.Fatalf("Navigation error: %v", toolOut.Error)
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000341 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700342 content := toolOut.LLMContent
Josh Bleecher Snydercb557262025-06-30 23:55:20 +0000343 if !strings.Contains(content[0].Text, "done") {
344 t.Fatalf("Expected done in navigation response, got: %s", content[0].Text)
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000345 }
346
347 // Check default viewport dimensions via JavaScript
348 evalInput := json.RawMessage(`{"expression": "({width: window.innerWidth, height: window.innerHeight})"}`)
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700349 toolOut = tools.NewEvalTool().Run(ctx, evalInput)
350 if toolOut.Error != nil {
351 t.Fatalf("Evaluation error: %v", toolOut.Error)
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000352 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700353 content = toolOut.LLMContent
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000354
355 // Parse the result to verify dimensions
356 var response struct {
Josh Bleecher Snyder553cc842025-07-07 19:05:22 -0700357 Width float64 `json:"width"`
358 Height float64 `json:"height"`
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000359 }
360
Josh Bleecher Snyder553cc842025-07-07 19:05:22 -0700361 text := content[0].Text
362 text = strings.TrimPrefix(text, "<javascript_result>")
363 text = strings.TrimSuffix(text, "</javascript_result>")
364
365 if err := json.Unmarshal([]byte(text), &response); err != nil {
366 t.Fatalf("Failed to parse evaluation response (%q => %q): %v", content[0].Text, text, err)
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000367 }
368
369 // Verify the default viewport size is 1280x720
370 expectedWidth := 1280.0
371 expectedHeight := 720.0
372
Josh Bleecher Snyder553cc842025-07-07 19:05:22 -0700373 if response.Width != expectedWidth {
374 t.Errorf("Expected default width %v, got %v", expectedWidth, response.Width)
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000375 }
Josh Bleecher Snyder553cc842025-07-07 19:05:22 -0700376 if response.Height != expectedHeight {
377 t.Errorf("Expected default height %v, got %v", expectedHeight, response.Height)
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000378 }
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000379}