blob: f82c35e76ce3ca75d2282846e49d7fb9f36d2e5b [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
308// TestResizeTool tests the browser resize functionality
309func TestResizeTool(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 t.Run("ResizeWindow", func(t *testing.T) {
319 tools := NewBrowseTools(ctx)
320 defer tools.Close()
321
322 // Resize to mobile dimensions
323 resizeTool := tools.NewResizeTool()
324 input := json.RawMessage(`{"width": 375, "height": 667}`)
325 content, err := resizeTool.Run(ctx, input)
Josh Bleecher Snyder6e353322025-05-21 18:21:24 +0000326 if err != nil {
327 t.Fatalf("Error: %v", err)
328 }
329 if !strings.Contains(content[0].Text, "success") {
330 t.Fatalf("Expected success in response, got: %s", content[0].Text)
331 }
Philip Zeyliger05224842025-05-10 18:26:08 -0700332
333 // Navigate to a test page and verify using JavaScript to get window dimensions
334 navInput := json.RawMessage(`{"url": "https://example.com"}`)
335 content, err = tools.NewNavigateTool().Run(ctx, navInput)
Josh Bleecher Snyder6e353322025-05-21 18:21:24 +0000336 if err != nil {
337 t.Fatalf("Error: %v", err)
338 }
339 if !strings.Contains(content[0].Text, "success") {
340 t.Fatalf("Expected success in response, got: %s", content[0].Text)
341 }
Philip Zeyliger05224842025-05-10 18:26:08 -0700342
343 // Check dimensions via JavaScript
344 evalInput := json.RawMessage(`{"expression": "({width: window.innerWidth, height: window.innerHeight})"}`)
345 content, err = tools.NewEvalTool().Run(ctx, evalInput)
Josh Bleecher Snyder6e353322025-05-21 18:21:24 +0000346 if err != nil {
347 t.Fatalf("Error: %v", err)
348 }
Philip Zeyliger05224842025-05-10 18:26:08 -0700349
350 // The dimensions might not be exactly what we set (browser chrome, etc.)
351 // but they should be close
Josh Bleecher Snyder6e353322025-05-21 18:21:24 +0000352 if !strings.Contains(content[0].Text, "width") {
353 t.Fatalf("Expected width in response, got: %s", content[0].Text)
354 }
355 if !strings.Contains(content[0].Text, "height") {
356 t.Fatalf("Expected height in response, got: %s", content[0].Text)
357 }
Philip Zeyliger05224842025-05-10 18:26:08 -0700358 })
359}