blob: 35d79113f7b17142e1f1b6afb04caea1ac079aba [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())
21
22 // Test each tool has correct name and description
23 toolTests := []struct {
24 tool *llm.Tool
25 expectedName string
26 shortDesc string
27 requiredProps []string
28 }{
29 {tools.NewNavigateTool(), "browser_navigate", "Navigate", []string{"url"}},
30 {tools.NewClickTool(), "browser_click", "Click", []string{"selector"}},
31 {tools.NewTypeTool(), "browser_type", "Type", []string{"selector", "text"}},
32 {tools.NewWaitForTool(), "browser_wait_for", "Wait", []string{"selector"}},
33 {tools.NewGetTextTool(), "browser_get_text", "Get", []string{"selector"}},
34 {tools.NewEvalTool(), "browser_eval", "Evaluate", []string{"expression"}},
Philip Zeyliger80b488d2025-05-10 18:21:54 -070035 {tools.NewScreenshotTool(), "browser_take_screenshot", "Take", nil},
Philip Zeyliger33d282f2025-05-03 04:01:54 +000036 {tools.NewScrollIntoViewTool(), "browser_scroll_into_view", "Scroll", []string{"selector"}},
37 }
38
39 for _, tt := range toolTests {
40 t.Run(tt.expectedName, func(t *testing.T) {
41 if tt.tool.Name != tt.expectedName {
42 t.Errorf("expected name %q, got %q", tt.expectedName, tt.tool.Name)
43 }
44
45 if !strings.Contains(tt.tool.Description, tt.shortDesc) {
46 t.Errorf("description %q should contain %q", tt.tool.Description, tt.shortDesc)
47 }
48
49 // Verify schema has required properties
50 if len(tt.requiredProps) > 0 {
51 var schema struct {
52 Required []string `json:"required"`
53 }
54 if err := json.Unmarshal(tt.tool.InputSchema, &schema); err != nil {
55 t.Fatalf("failed to unmarshal schema: %v", err)
56 }
57
58 for _, prop := range tt.requiredProps {
59 if !slices.Contains(schema.Required, prop) {
60 t.Errorf("property %q should be required", prop)
61 }
62 }
63 }
64 })
65 }
66}
67
Philip Zeyliger72252cb2025-05-10 17:00:08 -070068func TestGetTools(t *testing.T) {
Philip Zeyliger33d282f2025-05-03 04:01:54 +000069 // Create browser tools instance
70 tools := NewBrowseTools(context.Background())
71
Philip Zeyliger72252cb2025-05-10 17:00:08 -070072 // Test with screenshot tools included
73 t.Run("with screenshots", func(t *testing.T) {
74 toolsWithScreenshots := tools.GetTools(true)
Philip Zeyliger18e33682025-05-13 16:34:21 -070075 if len(toolsWithScreenshots) != 12 {
76 t.Errorf("expected 12 tools with screenshots, got %d", len(toolsWithScreenshots))
Philip Zeyliger33d282f2025-05-03 04:01:54 +000077 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -070078
79 // Check tool naming convention
80 for _, tool := range toolsWithScreenshots {
81 if !strings.HasPrefix(tool.Name, "browser_") {
82 t.Errorf("tool name %q does not have prefix 'browser_'", tool.Name)
83 }
84 }
85 })
86
87 // Test without screenshot tools
88 t.Run("without screenshots", func(t *testing.T) {
89 noScreenshotTools := tools.GetTools(false)
Philip Zeyliger18e33682025-05-13 16:34:21 -070090 if len(noScreenshotTools) != 10 {
91 t.Errorf("expected 10 tools without screenshots, got %d", len(noScreenshotTools))
Philip Zeyliger72252cb2025-05-10 17:00:08 -070092 }
93 })
Philip Zeyliger33d282f2025-05-03 04:01:54 +000094}
95
96// TestBrowserInitialization verifies that the browser can start correctly
97func TestBrowserInitialization(t *testing.T) {
98 // Skip long tests in short mode
99 if testing.Short() {
100 t.Skip("skipping browser initialization test in short mode")
101 }
102
103 // Create browser tools instance
Philip Zeyliger80b488d2025-05-10 18:21:54 -0700104 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000105 defer cancel()
106
107 tools := NewBrowseTools(ctx)
108
109 // Initialize the browser
110 err := tools.Initialize()
111 if err != nil {
112 // If browser automation is not available, skip the test
113 if strings.Contains(err.Error(), "browser automation not available") {
114 t.Skip("Browser automation not available in this environment")
115 } else {
116 t.Fatalf("Failed to initialize browser: %v", err)
117 }
118 }
119
120 // Clean up
121 defer tools.Close()
122
123 // Get browser context to verify it's working
124 browserCtx, err := tools.GetBrowserContext()
125 if err != nil {
126 t.Fatalf("Failed to get browser context: %v", err)
127 }
128
129 // Try to navigate to a simple page
130 var title string
131 err = chromedp.Run(browserCtx,
132 chromedp.Navigate("about:blank"),
133 chromedp.Title(&title),
134 )
135 if err != nil {
136 t.Fatalf("Failed to navigate to about:blank: %v", err)
137 }
138
139 t.Logf("Successfully navigated to about:blank, title: %q", title)
140}
141
142// TestNavigateTool verifies that the navigate tool works correctly
143func TestNavigateTool(t *testing.T) {
144 // Skip long tests in short mode
145 if testing.Short() {
146 t.Skip("skipping navigate tool test in short mode")
147 }
148
149 // Create browser tools instance
Philip Zeyliger80b488d2025-05-10 18:21:54 -0700150 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000151 defer cancel()
152
153 tools := NewBrowseTools(ctx)
154 defer tools.Close()
155
156 // Check if browser initialization works
157 if err := tools.Initialize(); err != nil {
158 if strings.Contains(err.Error(), "browser automation not available") {
159 t.Skip("Browser automation not available in this environment")
160 }
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 result, err := navTool.Run(ctx, json.RawMessage(inputJSON))
172 if err != nil {
173 t.Fatalf("Error running navigate tool: %v", err)
174 }
175
176 // Verify the response is successful
177 var response struct {
178 Status string `json:"status"`
179 Error string `json:"error,omitempty"`
180 }
181
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700182 resultText := result[0].Text
183 if err := json.Unmarshal([]byte(resultText), &response); err != nil {
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000184 t.Fatalf("Error unmarshaling response: %v", err)
185 }
186
187 if response.Status != "success" {
188 // If browser automation is not available, skip the test
189 if strings.Contains(response.Error, "browser automation not available") {
190 t.Skip("Browser automation not available in this environment")
191 } else {
192 t.Errorf("Expected status 'success', got '%s' with error: %s", response.Status, response.Error)
193 }
194 }
195
196 // Try to get the page title to verify the navigation worked
197 browserCtx, err := tools.GetBrowserContext()
198 if err != nil {
199 // If browser automation is not available, skip the test
200 if strings.Contains(err.Error(), "browser automation not available") {
201 t.Skip("Browser automation not available in this environment")
202 } else {
203 t.Fatalf("Failed to get browser context: %v", err)
204 }
205 }
206
207 var title string
208 err = chromedp.Run(browserCtx, chromedp.Title(&title))
209 if err != nil {
210 t.Fatalf("Failed to get page title: %v", err)
211 }
212
213 t.Logf("Successfully navigated to example.com, title: %q", title)
214 if title != "Example Domain" {
215 t.Errorf("Expected title 'Example Domain', got '%s'", title)
216 }
217}
218
219// TestScreenshotTool tests that the screenshot tool properly saves files
220func TestScreenshotTool(t *testing.T) {
221 // Create browser tools instance
222 ctx := context.Background()
223 tools := NewBrowseTools(ctx)
224
225 // Test SaveScreenshot function directly
226 testData := []byte("test image data")
227 id := tools.SaveScreenshot(testData)
228 if id == "" {
229 t.Fatal("SaveScreenshot returned empty ID")
230 }
231
232 // Get the file path and check if the file exists
233 filePath := GetScreenshotPath(id)
234 _, err := os.Stat(filePath)
235 if err != nil {
236 t.Fatalf("Failed to find screenshot file: %v", err)
237 }
238
239 // Read the file contents
240 contents, err := os.ReadFile(filePath)
241 if err != nil {
242 t.Fatalf("Failed to read screenshot file: %v", err)
243 }
244
245 // Check the file contents
246 if string(contents) != string(testData) {
247 t.Errorf("File contents don't match: expected %q, got %q", string(testData), string(contents))
248 }
249
250 // Clean up the test file
251 os.Remove(filePath)
252}
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700253
254func TestReadImageTool(t *testing.T) {
255 // Create a test BrowseTools instance
256 ctx := context.Background()
257 browseTools := NewBrowseTools(ctx)
258
259 // Create a test image
260 testDir := t.TempDir()
261 testImagePath := filepath.Join(testDir, "test_image.png")
262
263 // Create a small 1x1 black PNG image
264 smallPng := []byte{
265 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
266 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
267 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0x60, 0x00, 0x00, 0x00,
268 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
269 0x42, 0x60, 0x82,
270 }
271
272 // Write the test image
273 err := os.WriteFile(testImagePath, smallPng, 0o644)
274 if err != nil {
275 t.Fatalf("Failed to create test image: %v", err)
276 }
277
278 // Create the tool
279 readImageTool := browseTools.NewReadImageTool()
280
281 // Prepare input
282 input := fmt.Sprintf(`{"path": "%s"}`, testImagePath)
283
284 // Run the tool
285 result, err := readImageTool.Run(ctx, json.RawMessage(input))
286 if err != nil {
287 t.Fatalf("Read image tool failed: %v", err)
288 }
289
290 // In the updated code, result is already a []llm.Content
291 contents := result
292
293 // Check that we got at least two content objects
294 if len(contents) < 2 {
295 t.Fatalf("Expected at least 2 content objects, got %d", len(contents))
296 }
297
298 // Check that the second content has image data
299 if contents[1].MediaType == "" {
300 t.Errorf("Expected MediaType in second content")
301 }
302
303 if contents[1].Data == "" {
304 t.Errorf("Expected Data in second content")
305 }
306}
Philip Zeyliger05224842025-05-10 18:26:08 -0700307
Josh Bleecher Snyder7fbc8e42025-05-29 19:42:25 +0000308// TestDefaultViewportSize verifies that the browser starts with the correct default viewport size
309func TestDefaultViewportSize(t *testing.T) {
310 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
311 defer cancel()
312
313 // Skip if CI or headless testing environment
314 if os.Getenv("CI") != "" || os.Getenv("HEADLESS_TEST") != "" {
315 t.Skip("Skipping browser test in CI/headless environment")
316 }
317
318 tools := NewBrowseTools(ctx)
319 defer tools.Close()
320
321 // Initialize browser (which should set default viewport to 1280x720)
322 err := tools.Initialize()
323 if err != nil {
324 if strings.Contains(err.Error(), "browser automation not available") {
325 t.Skip("Browser automation not available in this environment")
326 } else {
327 t.Fatalf("Failed to initialize browser: %v", err)
328 }
329 }
330
331 // Navigate to a simple page to ensure the browser is ready
332 navInput := json.RawMessage(`{"url": "about:blank"}`)
333 content, err := tools.NewNavigateTool().Run(ctx, navInput)
334 if err != nil {
335 t.Fatalf("Navigation error: %v", err)
336 }
337 if !strings.Contains(content[0].Text, "success") {
338 t.Fatalf("Expected success in navigation response, got: %s", content[0].Text)
339 }
340
341 // Check default viewport dimensions via JavaScript
342 evalInput := json.RawMessage(`{"expression": "({width: window.innerWidth, height: window.innerHeight})"}`)
343 content, err = tools.NewEvalTool().Run(ctx, evalInput)
344 if err != nil {
345 t.Fatalf("Evaluation error: %v", err)
346 }
347
348 // Parse the result to verify dimensions
349 var response struct {
350 Result struct {
351 Width float64 `json:"width"`
352 Height float64 `json:"height"`
353 } `json:"result"`
354 }
355
356 if err := json.Unmarshal([]byte(content[0].Text), &response); err != nil {
357 t.Fatalf("Failed to parse evaluation response: %v", err)
358 }
359
360 // Verify the default viewport size is 1280x720
361 expectedWidth := 1280.0
362 expectedHeight := 720.0
363
364 if response.Result.Width != expectedWidth {
365 t.Errorf("Expected default width %v, got %v", expectedWidth, response.Result.Width)
366 }
367
368 if response.Result.Height != expectedHeight {
369 t.Errorf("Expected default height %v, got %v", expectedHeight, response.Result.Height)
370 }
371
372 t.Logf("✅ Default viewport size verified: %vx%v", response.Result.Width, response.Result.Height)
373}
374
Philip Zeyliger05224842025-05-10 18:26:08 -0700375// TestResizeTool tests the browser resize functionality
376func TestResizeTool(t *testing.T) {
377 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
378 defer cancel()
379
380 // Skip if CI or headless testing environment
381 if os.Getenv("CI") != "" || os.Getenv("HEADLESS_TEST") != "" {
382 t.Skip("Skipping browser test in CI/headless environment")
383 }
384
385 t.Run("ResizeWindow", func(t *testing.T) {
386 tools := NewBrowseTools(ctx)
387 defer tools.Close()
388
389 // Resize to mobile dimensions
390 resizeTool := tools.NewResizeTool()
391 input := json.RawMessage(`{"width": 375, "height": 667}`)
392 content, err := resizeTool.Run(ctx, input)
Josh Bleecher Snyder6e353322025-05-21 18:21:24 +0000393 if err != nil {
394 t.Fatalf("Error: %v", err)
395 }
396 if !strings.Contains(content[0].Text, "success") {
397 t.Fatalf("Expected success in response, got: %s", content[0].Text)
398 }
Philip Zeyliger05224842025-05-10 18:26:08 -0700399
400 // Navigate to a test page and verify using JavaScript to get window dimensions
401 navInput := json.RawMessage(`{"url": "https://example.com"}`)
402 content, err = tools.NewNavigateTool().Run(ctx, navInput)
Josh Bleecher Snyder6e353322025-05-21 18:21:24 +0000403 if err != nil {
404 t.Fatalf("Error: %v", err)
405 }
406 if !strings.Contains(content[0].Text, "success") {
407 t.Fatalf("Expected success in response, got: %s", content[0].Text)
408 }
Philip Zeyliger05224842025-05-10 18:26:08 -0700409
410 // Check dimensions via JavaScript
411 evalInput := json.RawMessage(`{"expression": "({width: window.innerWidth, height: window.innerHeight})"}`)
412 content, err = tools.NewEvalTool().Run(ctx, evalInput)
Josh Bleecher Snyder6e353322025-05-21 18:21:24 +0000413 if err != nil {
414 t.Fatalf("Error: %v", err)
415 }
Philip Zeyliger05224842025-05-10 18:26:08 -0700416
417 // The dimensions might not be exactly what we set (browser chrome, etc.)
418 // but they should be close
Josh Bleecher Snyder6e353322025-05-21 18:21:24 +0000419 if !strings.Contains(content[0].Text, "width") {
420 t.Fatalf("Expected width in response, got: %s", content[0].Text)
421 }
422 if !strings.Contains(content[0].Text, "height") {
423 t.Fatalf("Expected height in response, got: %s", content[0].Text)
424 }
Philip Zeyliger05224842025-05-10 18:26:08 -0700425 })
426}