blob: 024f685ebd9ed9af0303453b74f723f221addbb0 [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"}},
33 {tools.NewClickTool(), "browser_click", "Click", []string{"selector"}},
34 {tools.NewTypeTool(), "browser_type", "Type", []string{"selector", "text"}},
35 {tools.NewWaitForTool(), "browser_wait_for", "Wait", []string{"selector"}},
36 {tools.NewGetTextTool(), "browser_get_text", "Get", []string{"selector"}},
37 {tools.NewEvalTool(), "browser_eval", "Evaluate", []string{"expression"}},
Philip Zeyliger80b488d2025-05-10 18:21:54 -070038 {tools.NewScreenshotTool(), "browser_take_screenshot", "Take", nil},
Philip Zeyliger33d282f2025-05-03 04:01:54 +000039 {tools.NewScrollIntoViewTool(), "browser_scroll_into_view", "Scroll", []string{"selector"}},
40 }
41
42 for _, tt := range toolTests {
43 t.Run(tt.expectedName, func(t *testing.T) {
44 if tt.tool.Name != tt.expectedName {
45 t.Errorf("expected name %q, got %q", tt.expectedName, tt.tool.Name)
46 }
47
48 if !strings.Contains(tt.tool.Description, tt.shortDesc) {
49 t.Errorf("description %q should contain %q", tt.tool.Description, tt.shortDesc)
50 }
51
52 // Verify schema has required properties
53 if len(tt.requiredProps) > 0 {
54 var schema struct {
55 Required []string `json:"required"`
56 }
57 if err := json.Unmarshal(tt.tool.InputSchema, &schema); err != nil {
58 t.Fatalf("failed to unmarshal schema: %v", err)
59 }
60
61 for _, prop := range tt.requiredProps {
62 if !slices.Contains(schema.Required, prop) {
63 t.Errorf("property %q should be required", prop)
64 }
65 }
66 }
67 })
68 }
69}
70
Philip Zeyliger72252cb2025-05-10 17:00:08 -070071func TestGetTools(t *testing.T) {
Philip Zeyliger33d282f2025-05-03 04:01:54 +000072 // Create browser tools instance
73 tools := NewBrowseTools(context.Background())
Philip Zeyligera35de5f2025-06-14 12:00:48 -070074 t.Cleanup(func() {
75 tools.Close()
76 })
Philip Zeyliger33d282f2025-05-03 04:01:54 +000077
Philip Zeyliger72252cb2025-05-10 17:00:08 -070078 // Test with screenshot tools included
79 t.Run("with screenshots", func(t *testing.T) {
80 toolsWithScreenshots := tools.GetTools(true)
Philip Zeyliger18e33682025-05-13 16:34:21 -070081 if len(toolsWithScreenshots) != 12 {
82 t.Errorf("expected 12 tools with screenshots, got %d", len(toolsWithScreenshots))
Philip Zeyliger33d282f2025-05-03 04:01:54 +000083 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -070084
85 // Check tool naming convention
86 for _, tool := range toolsWithScreenshots {
Philip Zeyliger542bda32025-06-11 18:31:03 -070087 // Most tools have browser_ prefix, except for read_image
88 if tool.Name != "read_image" && !strings.HasPrefix(tool.Name, "browser_") {
Philip Zeyliger72252cb2025-05-10 17:00:08 -070089 t.Errorf("tool name %q does not have prefix 'browser_'", tool.Name)
90 }
91 }
92 })
93
94 // Test without screenshot tools
95 t.Run("without screenshots", func(t *testing.T) {
96 noScreenshotTools := tools.GetTools(false)
Philip Zeyliger18e33682025-05-13 16:34:21 -070097 if len(noScreenshotTools) != 10 {
98 t.Errorf("expected 10 tools without screenshots, got %d", len(noScreenshotTools))
Philip Zeyliger72252cb2025-05-10 17:00:08 -070099 }
100 })
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000101}
102
103// TestBrowserInitialization verifies that the browser can start correctly
104func TestBrowserInitialization(t *testing.T) {
105 // Skip long tests in short mode
106 if testing.Short() {
107 t.Skip("skipping browser initialization test in short mode")
108 }
109
110 // Create browser tools instance
Philip Zeyliger29c481c2025-06-15 21:25:45 -0700111 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000112 defer cancel()
113
114 tools := NewBrowseTools(ctx)
Philip Zeyligera35de5f2025-06-14 12:00:48 -0700115 t.Cleanup(func() {
116 tools.Close()
117 })
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000118
119 // Initialize the browser
120 err := tools.Initialize()
121 if err != nil {
122 // If browser automation is not available, skip the test
123 if strings.Contains(err.Error(), "browser automation not available") {
124 t.Skip("Browser automation not available in this environment")
125 } else {
126 t.Fatalf("Failed to initialize browser: %v", err)
127 }
128 }
129
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000130 // Get browser context to verify it's working
131 browserCtx, err := tools.GetBrowserContext()
132 if err != nil {
133 t.Fatalf("Failed to get browser context: %v", err)
134 }
135
136 // Try to navigate to a simple page
137 var title string
138 err = chromedp.Run(browserCtx,
139 chromedp.Navigate("about:blank"),
140 chromedp.Title(&title),
141 )
142 if err != nil {
143 t.Fatalf("Failed to navigate to about:blank: %v", err)
144 }
145
146 t.Logf("Successfully navigated to about:blank, title: %q", title)
147}
148
149// TestNavigateTool verifies that the navigate tool works correctly
150func TestNavigateTool(t *testing.T) {
151 // Skip long tests in short mode
152 if testing.Short() {
153 t.Skip("skipping navigate tool test in short mode")
154 }
155
156 // Create browser tools instance
Philip Zeyliger29c481c2025-06-15 21:25:45 -0700157 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000158 defer cancel()
159
160 tools := NewBrowseTools(ctx)
Philip Zeyligera35de5f2025-06-14 12:00:48 -0700161 t.Cleanup(func() {
162 tools.Close()
163 })
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000164
165 // Check if browser initialization works
166 if err := tools.Initialize(); err != nil {
167 if strings.Contains(err.Error(), "browser automation not available") {
168 t.Skip("Browser automation not available in this environment")
169 }
170 }
171
172 // Get the navigate tool
173 navTool := tools.NewNavigateTool()
174
175 // Create input for the navigate tool
176 input := map[string]string{"url": "https://example.com"}
177 inputJSON, _ := json.Marshal(input)
178
179 // Call the tool
180 result, err := navTool.Run(ctx, json.RawMessage(inputJSON))
181 if err != nil {
182 t.Fatalf("Error running navigate tool: %v", err)
183 }
184
185 // Verify the response is successful
186 var response struct {
187 Status string `json:"status"`
188 Error string `json:"error,omitempty"`
189 }
190
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700191 resultText := result[0].Text
192 if err := json.Unmarshal([]byte(resultText), &response); err != nil {
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000193 t.Fatalf("Error unmarshaling response: %v", err)
194 }
195
196 if response.Status != "success" {
197 // If browser automation is not available, skip the test
198 if strings.Contains(response.Error, "browser automation not available") {
199 t.Skip("Browser automation not available in this environment")
200 } else {
201 t.Errorf("Expected status 'success', got '%s' with error: %s", response.Status, response.Error)
202 }
203 }
204
205 // Try to get the page title to verify the navigation worked
206 browserCtx, err := tools.GetBrowserContext()
207 if err != nil {
208 // If browser automation is not available, skip the test
209 if strings.Contains(err.Error(), "browser automation not available") {
210 t.Skip("Browser automation not available in this environment")
211 } else {
212 t.Fatalf("Failed to get browser context: %v", err)
213 }
214 }
215
216 var title string
217 err = chromedp.Run(browserCtx, chromedp.Title(&title))
218 if err != nil {
219 t.Fatalf("Failed to get page title: %v", err)
220 }
221
222 t.Logf("Successfully navigated to example.com, title: %q", title)
223 if title != "Example Domain" {
224 t.Errorf("Expected title 'Example Domain', got '%s'", title)
225 }
226}
227
228// TestScreenshotTool tests that the screenshot tool properly saves files
229func TestScreenshotTool(t *testing.T) {
230 // Create browser tools instance
231 ctx := context.Background()
232 tools := NewBrowseTools(ctx)
Philip Zeyligera35de5f2025-06-14 12:00:48 -0700233 t.Cleanup(func() {
234 tools.Close()
235 })
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000236
237 // Test SaveScreenshot function directly
238 testData := []byte("test image data")
239 id := tools.SaveScreenshot(testData)
240 if id == "" {
241 t.Fatal("SaveScreenshot returned empty ID")
242 }
243
244 // Get the file path and check if the file exists
245 filePath := GetScreenshotPath(id)
246 _, err := os.Stat(filePath)
247 if err != nil {
248 t.Fatalf("Failed to find screenshot file: %v", err)
249 }
250
251 // Read the file contents
252 contents, err := os.ReadFile(filePath)
253 if err != nil {
254 t.Fatalf("Failed to read screenshot file: %v", err)
255 }
256
257 // Check the file contents
258 if string(contents) != string(testData) {
259 t.Errorf("File contents don't match: expected %q, got %q", string(testData), string(contents))
260 }
261
262 // Clean up the test file
263 os.Remove(filePath)
264}
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700265
266func TestReadImageTool(t *testing.T) {
267 // Create a test BrowseTools instance
268 ctx := context.Background()
269 browseTools := NewBrowseTools(ctx)
Philip Zeyligera35de5f2025-06-14 12:00:48 -0700270 t.Cleanup(func() {
271 browseTools.Close()
272 })
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700273
274 // Create a test image
275 testDir := t.TempDir()
276 testImagePath := filepath.Join(testDir, "test_image.png")
277
278 // Create a small 1x1 black PNG image
279 smallPng := []byte{
280 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
281 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
282 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0x60, 0x00, 0x00, 0x00,
283 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
284 0x42, 0x60, 0x82,
285 }
286
287 // Write the test image
288 err := os.WriteFile(testImagePath, smallPng, 0o644)
289 if err != nil {
290 t.Fatalf("Failed to create test image: %v", err)
291 }
292
293 // Create the tool
294 readImageTool := browseTools.NewReadImageTool()
295
296 // Prepare input
297 input := fmt.Sprintf(`{"path": "%s"}`, testImagePath)
298
299 // Run the tool
300 result, err := readImageTool.Run(ctx, json.RawMessage(input))
301 if err != nil {
302 t.Fatalf("Read image tool failed: %v", err)
303 }
304
305 // In the updated code, result is already a []llm.Content
306 contents := result
307
308 // Check that we got at least two content objects
309 if len(contents) < 2 {
310 t.Fatalf("Expected at least 2 content objects, got %d", len(contents))
311 }
312
313 // Check that the second content has image data
314 if contents[1].MediaType == "" {
315 t.Errorf("Expected MediaType in second content")
316 }
317
318 if contents[1].Data == "" {
319 t.Errorf("Expected Data in second content")
320 }
321}
Philip Zeyliger05224842025-05-10 18:26:08 -0700322
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000323// TestDefaultViewportSize verifies that the browser starts with the correct default viewport size
324func TestDefaultViewportSize(t *testing.T) {
Philip Zeyliger29c481c2025-06-15 21:25:45 -0700325 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000326 defer cancel()
327
328 // Skip if CI or headless testing environment
329 if os.Getenv("CI") != "" || os.Getenv("HEADLESS_TEST") != "" {
330 t.Skip("Skipping browser test in CI/headless environment")
331 }
332
333 tools := NewBrowseTools(ctx)
Philip Zeyligera35de5f2025-06-14 12:00:48 -0700334 t.Cleanup(func() {
335 tools.Close()
336 })
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000337
338 // Initialize browser (which should set default viewport to 1280x720)
339 err := tools.Initialize()
340 if err != nil {
341 if strings.Contains(err.Error(), "browser automation not available") {
342 t.Skip("Browser automation not available in this environment")
343 } else {
344 t.Fatalf("Failed to initialize browser: %v", err)
345 }
346 }
347
348 // Navigate to a simple page to ensure the browser is ready
349 navInput := json.RawMessage(`{"url": "about:blank"}`)
350 content, err := tools.NewNavigateTool().Run(ctx, navInput)
351 if err != nil {
352 t.Fatalf("Navigation error: %v", err)
353 }
354 if !strings.Contains(content[0].Text, "success") {
355 t.Fatalf("Expected success in navigation response, got: %s", content[0].Text)
356 }
357
358 // Check default viewport dimensions via JavaScript
359 evalInput := json.RawMessage(`{"expression": "({width: window.innerWidth, height: window.innerHeight})"}`)
360 content, err = tools.NewEvalTool().Run(ctx, evalInput)
361 if err != nil {
362 t.Fatalf("Evaluation error: %v", err)
363 }
364
365 // Parse the result to verify dimensions
366 var response struct {
367 Result struct {
368 Width float64 `json:"width"`
369 Height float64 `json:"height"`
370 } `json:"result"`
371 }
372
373 if err := json.Unmarshal([]byte(content[0].Text), &response); err != nil {
374 t.Fatalf("Failed to parse evaluation response: %v", err)
375 }
376
377 // Verify the default viewport size is 1280x720
378 expectedWidth := 1280.0
379 expectedHeight := 720.0
380
381 if response.Result.Width != expectedWidth {
382 t.Errorf("Expected default width %v, got %v", expectedWidth, response.Result.Width)
383 }
384
385 if response.Result.Height != expectedHeight {
386 t.Errorf("Expected default height %v, got %v", expectedHeight, response.Result.Height)
387 }
388
389 t.Logf("✅ Default viewport size verified: %vx%v", response.Result.Width, response.Result.Height)
390}
391
Philip Zeyliger05224842025-05-10 18:26:08 -0700392// TestResizeTool tests the browser resize functionality
393func TestResizeTool(t *testing.T) {
Philip Zeyliger29c481c2025-06-15 21:25:45 -0700394 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
Philip Zeyliger05224842025-05-10 18:26:08 -0700395 defer cancel()
396
397 // Skip if CI or headless testing environment
398 if os.Getenv("CI") != "" || os.Getenv("HEADLESS_TEST") != "" {
399 t.Skip("Skipping browser test in CI/headless environment")
400 }
401
402 t.Run("ResizeWindow", func(t *testing.T) {
403 tools := NewBrowseTools(ctx)
Philip Zeyligera35de5f2025-06-14 12:00:48 -0700404 t.Cleanup(func() {
405 tools.Close()
406 })
Philip Zeyliger05224842025-05-10 18:26:08 -0700407
408 // Resize to mobile dimensions
409 resizeTool := tools.NewResizeTool()
410 input := json.RawMessage(`{"width": 375, "height": 667}`)
411 content, err := resizeTool.Run(ctx, input)
Josh Bleecher Snyder6e353322025-05-21 18:21:24 +0000412 if err != nil {
413 t.Fatalf("Error: %v", err)
414 }
415 if !strings.Contains(content[0].Text, "success") {
416 t.Fatalf("Expected success in response, got: %s", content[0].Text)
417 }
Philip Zeyliger05224842025-05-10 18:26:08 -0700418
419 // Navigate to a test page and verify using JavaScript to get window dimensions
420 navInput := json.RawMessage(`{"url": "https://example.com"}`)
421 content, err = tools.NewNavigateTool().Run(ctx, navInput)
Josh Bleecher Snyder6e353322025-05-21 18:21:24 +0000422 if err != nil {
423 t.Fatalf("Error: %v", err)
424 }
425 if !strings.Contains(content[0].Text, "success") {
426 t.Fatalf("Expected success in response, got: %s", content[0].Text)
427 }
Philip Zeyliger05224842025-05-10 18:26:08 -0700428
429 // Check dimensions via JavaScript
430 evalInput := json.RawMessage(`{"expression": "({width: window.innerWidth, height: window.innerHeight})"}`)
431 content, err = tools.NewEvalTool().Run(ctx, evalInput)
Josh Bleecher Snyder6e353322025-05-21 18:21:24 +0000432 if err != nil {
433 t.Fatalf("Error: %v", err)
434 }
Philip Zeyliger05224842025-05-10 18:26:08 -0700435
436 // The dimensions might not be exactly what we set (browser chrome, etc.)
437 // but they should be close
Josh Bleecher Snyder6e353322025-05-21 18:21:24 +0000438 if !strings.Contains(content[0].Text, "width") {
439 t.Fatalf("Expected width in response, got: %s", content[0].Text)
440 }
441 if !strings.Contains(content[0].Text, "height") {
442 t.Fatalf("Expected height in response, got: %s", content[0].Text)
443 }
Philip Zeyliger05224842025-05-10 18:26:08 -0700444 })
445}