diff --git a/packages/engine/internal/connector/browser_declarative.go b/packages/engine/internal/connector/browser_declarative.go new file mode 100644 index 0000000..4698c16 --- /dev/null +++ b/packages/engine/internal/connector/browser_declarative.go @@ -0,0 +1,455 @@ +package connector + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// BrowserSession holds serialized browser state passed between declarative steps. +type BrowserSession struct { + Cookies []map[string]any `json:"cookies"` + LocalStorage map[string]map[string]string `json:"local_storage"` + URL string `json:"url"` +} + +// extractSession parses the optional session_state param into a *BrowserSession. +// Returns nil (no error) when absent or nil — callers treat nil as "start fresh". +func extractSession(params map[string]any) (*BrowserSession, error) { + raw, ok := params["session_state"] + if !ok || raw == nil { + return nil, nil + } + b, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("browser: marshaling session_state: %w", err) + } + var s BrowserSession + if err := json.Unmarshal(b, &s); err != nil { + return nil, fmt.Errorf("browser: invalid session_state: %w", err) + } + return &s, nil +} + +// buildDeclarativeScript generates a self-contained Playwright JS script. +// +// actionSnippet is injected into the page context after optional session +// restore. skipURLRestore=true restores cookies but skips navigating to the +// previous URL — used by browser/navigate, which sets its own destination. +func buildDeclarativeScript(actionSnippet string, session *BrowserSession, timeoutMs int, skipURLRestore bool) (string, error) { + var b strings.Builder + + b.WriteString("const { chromium } = require('playwright');\n") + b.WriteString("(async () => {\n") + b.WriteString(" let actionData = {};\n") + b.WriteString(" const browser = await chromium.launch({ headless: true });\n") + b.WriteString(" try {\n") + b.WriteString(" const context = await browser.newContext();\n") + + if session != nil && len(session.Cookies) > 0 { + cookiesJSON, err := json.Marshal(session.Cookies) + if err != nil { + return "", fmt.Errorf("browser: marshaling cookies: %w", err) + } + fmt.Fprintf(&b, " await context.addCookies(%s);\n", cookiesJSON) + } + + b.WriteString(" const page = await context.newPage();\n") + fmt.Fprintf(&b, " page.setDefaultTimeout(%d);\n", timeoutMs) + + if session != nil && session.URL != "" && !skipURLRestore { + urlJSON, err := json.Marshal(session.URL) + if err != nil { + return "", fmt.Errorf("browser: marshaling URL: %w", err) + } + fmt.Fprintf(&b, " await page.goto(%s);\n", urlJSON) + // Note: localStorage is keyed by origin. If the page at session.URL redirects + // to a different origin, window.location.origin after navigation won't match + // the stored key and localStorage will silently not be restored. This is an + // inherent limitation of post-navigation injection; use addInitScript for + // redirect-safe restore if this becomes an issue. + if len(session.LocalStorage) > 0 { + lsJSON, err := json.Marshal(session.LocalStorage) + if err != nil { + return "", fmt.Errorf("browser: marshaling localStorage: %w", err) + } + fmt.Fprintf(&b, " await page.evaluate((ls) => {\n") + b.WriteString(" const origin = window.location.origin;\n") + b.WriteString(" for (const [k, v] of Object.entries(ls[origin] || {})) localStorage.setItem(k, v);\n") + fmt.Fprintf(&b, " }, %s);\n", lsJSON) + } + } + + // Inject action snippet with consistent indentation. + for _, line := range strings.Split(strings.TrimRight(actionSnippet, "\n"), "\n") { + fmt.Fprintf(&b, " %s\n", line) + } + + // Capture session state after the action. + b.WriteString(" const _outCookies = await context.cookies();\n") + b.WriteString(" const _outLS = {};\n") + b.WriteString(" const _url = page.url();\n") + b.WriteString(" try {\n") + b.WriteString(" const _origin = new URL(_url).origin;\n") + b.WriteString(" _outLS[_origin] = await page.evaluate(() => {\n") + b.WriteString(" const d = {};\n") + b.WriteString(" for (let i = 0; i < localStorage.length; i++) {\n") + b.WriteString(" const k = localStorage.key(i); d[k] = localStorage.getItem(k);\n") + b.WriteString(" }\n") + b.WriteString(" return d;\n") + b.WriteString(" });\n") + b.WriteString(" } catch (_) {}\n") + b.WriteString(" console.log(JSON.stringify({\n") + b.WriteString(" session_state: { cookies: _outCookies, local_storage: _outLS, url: _url },\n") + b.WriteString(" data: actionData\n") + b.WriteString(" }));\n") + b.WriteString(" } finally {\n") + b.WriteString(" await browser.close();\n") + b.WriteString(" }\n") + b.WriteString("})().catch(err => { process.stderr.write(err.message + '\\n'); process.exit(1); });\n") + + return b.String(), nil +} + +// extractTimeoutMs returns the timeout_ms param as an int, defaulting to 30000. +func extractTimeoutMs(params map[string]any) int { + if v, ok := params["timeout_ms"]; ok { + switch t := v.(type) { + case int: + return t + case int64: + return int(t) + case float64: + return int(t) + } + } + return 30000 +} + +// executeBrowserScript runs a generated Playwright script via DockerRunConnector +// and returns the parsed JSON envelope { session_state, data }. +// +// Passes through pull, memory, and _credential from params. +func executeBrowserScript(ctx context.Context, script string, params map[string]any) (map[string]any, error) { + memory, _ := params["memory"].(string) + if memory == "" { + memory = "1g" + } + // playwrightNodeImage and playwrightVersion are defined in browser.go (same package). + dockerParams := map[string]any{ + "image": playwrightNodeImage, + "cmd": toAnySlice([]string{ + "sh", "-c", + "npm install --no-save --silent playwright@" + playwrightVersion + " && node", + }), + "stdin": script, + "network": "bridge", + "remove": true, + "memory": memory, + } + if pull, ok := params["pull"].(string); ok && pull != "" { + dockerParams["pull"] = pull + } + if cred, ok := params["_credential"]; ok { + dockerParams["_credential"] = cred + } + + docker := &DockerRunConnector{} + result, err := docker.Execute(ctx, dockerParams) + if err != nil { + return nil, err + } + if code, _ := result["exit_code"].(int64); code != 0 { + stderr, _ := result["stderr"].(string) + return nil, fmt.Errorf("script failed (exit %d): %s", code, strings.TrimSpace(stderr)) + } + stdout, _ := result["stdout"].(string) + var envelope map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope); err != nil { + return nil, fmt.Errorf("invalid output JSON: %w", err) + } + return envelope, nil +} + +// mustJSONString returns the JSON encoding of s as a string literal (e.g. "\"hello\""). +// It panics only if json.Marshal fails on a plain string, which cannot happen. +func mustJSONString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +// BrowserNavigateConnector implements browser/navigate. +type BrowserNavigateConnector struct{} + +func (c *BrowserNavigateConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + url, _ := params["url"].(string) + if strings.TrimSpace(url) == "" { + return nil, fmt.Errorf("browser/navigate: url is required") + } + waitUntil, _ := params["wait_until"].(string) + if waitUntil == "" { + waitUntil = "load" + } + switch waitUntil { + case "load", "networkidle", "domcontentloaded": + default: + return nil, fmt.Errorf("browser/navigate: wait_until must be load, networkidle, or domcontentloaded, got %q", waitUntil) + } + session, err := extractSession(params) + if err != nil { + return nil, fmt.Errorf("browser/navigate: %w", err) + } + snippet := fmt.Sprintf( + "await page.goto(%s, { waitUntil: %s });\nactionData.title = await page.title();", + mustJSONString(url), mustJSONString(waitUntil), + ) + script, err := buildDeclarativeScript(snippet, session, extractTimeoutMs(params), true) + if err != nil { + return nil, fmt.Errorf("browser/navigate: %w", err) + } + envelope, err := executeBrowserScript(ctx, script, params) + if err != nil { + return nil, fmt.Errorf("browser/navigate: %w", err) + } + out := map[string]any{"session_state": envelope["session_state"]} + if data, ok := envelope["data"].(map[string]any); ok { + if v, ok := data["title"]; ok { + out["title"] = v + } + } + return out, nil +} + +// BrowserEvaluateConnector implements browser/evaluate. +type BrowserEvaluateConnector struct{} + +func (c *BrowserEvaluateConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + expression, _ := params["expression"].(string) + if strings.TrimSpace(expression) == "" { + return nil, fmt.Errorf("browser/evaluate: expression is required") + } + session, err := extractSession(params) + if err != nil { + return nil, fmt.Errorf("browser/evaluate: %w", err) + } + // expression is embedded verbatim as a JS expression — callers are responsible for valid JS. + snippet := fmt.Sprintf("actionData.result = await page.evaluate(() => { return (%s); });\n", expression) + script, err := buildDeclarativeScript(snippet, session, extractTimeoutMs(params), false) + if err != nil { + return nil, fmt.Errorf("browser/evaluate: %w", err) + } + envelope, err := executeBrowserScript(ctx, script, params) + if err != nil { + return nil, fmt.Errorf("browser/evaluate: %w", err) + } + out := map[string]any{"session_state": envelope["session_state"]} + if data, ok := envelope["data"].(map[string]any); ok { + if v, ok := data["result"]; ok { + out["result"] = v + } + } + return out, nil +} + +// BrowserWaitConnector implements browser/wait. +// Exactly one of selector, url_pattern, or duration_ms must be provided. +type BrowserWaitConnector struct{} + +func (c *BrowserWaitConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + selector, hasSelector := params["selector"].(string) + urlPattern, hasURLPattern := params["url_pattern"].(string) + durationRaw, hasDuration := params["duration_ms"] + + count := 0 + if hasSelector && selector != "" { + count++ + } + if hasURLPattern && urlPattern != "" { + count++ + } + if hasDuration { + count++ + } + if count != 1 { + return nil, fmt.Errorf("browser/wait: exactly one of selector, url_pattern, or duration_ms must be provided") + } + + session, err := extractSession(params) + if err != nil { + return nil, fmt.Errorf("browser/wait: %w", err) + } + + var snippet string + switch { + case hasSelector && selector != "": + snippet = fmt.Sprintf("await page.waitForSelector(%s);\n", mustJSONString(selector)) + case hasURLPattern && urlPattern != "": + snippet = fmt.Sprintf("await page.waitForURL(%s);\n", mustJSONString(urlPattern)) + default: + var ms int + switch v := durationRaw.(type) { + case int: + ms = v + case int64: + ms = int(v) + case float64: + ms = int(v) + } + snippet = fmt.Sprintf("await page.waitForTimeout(%d);\n", ms) + } + + script, err := buildDeclarativeScript(snippet, session, extractTimeoutMs(params), false) + if err != nil { + return nil, fmt.Errorf("browser/wait: %w", err) + } + envelope, err := executeBrowserScript(ctx, script, params) + if err != nil { + return nil, fmt.Errorf("browser/wait: %w", err) + } + return map[string]any{"session_state": envelope["session_state"]}, nil +} + +// BrowserScreenshotConnector implements browser/screenshot. +type BrowserScreenshotConnector struct{} + +func (c *BrowserScreenshotConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + fullPage, _ := params["full_page"].(bool) + path, _ := params["path"].(string) + if path == "" { + path = "screenshot.png" + } + session, err := extractSession(params) + if err != nil { + return nil, fmt.Errorf("browser/screenshot: %w", err) + } + snippet := fmt.Sprintf( + "const buf = await page.screenshot({ fullPage: %v, path: undefined });\nactionData.base64 = buf.toString('base64');\nactionData.path = %s;", + fullPage, mustJSONString(path), + ) + script, err := buildDeclarativeScript(snippet, session, extractTimeoutMs(params), false) + if err != nil { + return nil, fmt.Errorf("browser/screenshot: %w", err) + } + envelope, err := executeBrowserScript(ctx, script, params) + if err != nil { + return nil, fmt.Errorf("browser/screenshot: %w", err) + } + out := map[string]any{"session_state": envelope["session_state"]} + if data, ok := envelope["data"].(map[string]any); ok { + if v, ok := data["base64"]; ok { + out["base64"] = v + } + if v, ok := data["path"]; ok { + out["path"] = v + } + } + return out, nil +} + +// BrowserExtractConnector implements browser/extract. +type BrowserExtractConnector struct{} + +func (c *BrowserExtractConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + selectorsRaw, ok := params["selectors"] + if !ok || selectorsRaw == nil { + return nil, fmt.Errorf("browser/extract: selectors is required") + } + selectorsMap, ok := selectorsRaw.(map[string]any) + if !ok || len(selectorsMap) == 0 { + return nil, fmt.Errorf("browser/extract: selectors must be a non-empty map of name to selector") + } + attribute, _ := params["attribute"].(string) + session, err := extractSession(params) + if err != nil { + return nil, fmt.Errorf("browser/extract: %w", err) + } + var snippet strings.Builder + snippet.WriteString("actionData.data = {};\n") + for name, sel := range selectorsMap { + selector, _ := sel.(string) + if attribute != "" { + fmt.Fprintf(&snippet, "actionData.data[%s] = await page.getAttribute(%s, %s);\n", + mustJSONString(name), mustJSONString(selector), mustJSONString(attribute)) + } else { + fmt.Fprintf(&snippet, "actionData.data[%s] = await page.textContent(%s);\n", + mustJSONString(name), mustJSONString(selector)) + } + } + script, err := buildDeclarativeScript(snippet.String(), session, extractTimeoutMs(params), false) + if err != nil { + return nil, fmt.Errorf("browser/extract: %w", err) + } + envelope, err := executeBrowserScript(ctx, script, params) + if err != nil { + return nil, fmt.Errorf("browser/extract: %w", err) + } + out := map[string]any{"session_state": envelope["session_state"]} + if data, ok := envelope["data"].(map[string]any); ok { + if d, ok := data["data"]; ok { + out["data"] = d + } + } + return out, nil +} + +// BrowserFillConnector implements browser/fill. +type BrowserFillConnector struct{} + +func (c *BrowserFillConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + fieldsRaw, ok := params["fields"] + if !ok || fieldsRaw == nil { + return nil, fmt.Errorf("browser/fill: fields is required") + } + fieldsMap, ok := fieldsRaw.(map[string]any) + if !ok || len(fieldsMap) == 0 { + return nil, fmt.Errorf("browser/fill: fields must be a non-empty map of selector to value") + } + session, err := extractSession(params) + if err != nil { + return nil, fmt.Errorf("browser/fill: %w", err) + } + var snippet strings.Builder + for selector, value := range fieldsMap { + v, _ := value.(string) + fmt.Fprintf(&snippet, "await page.fill(%s, %s);\n", mustJSONString(selector), mustJSONString(v)) + } + script, err := buildDeclarativeScript(snippet.String(), session, extractTimeoutMs(params), false) + if err != nil { + return nil, fmt.Errorf("browser/fill: %w", err) + } + envelope, err := executeBrowserScript(ctx, script, params) + if err != nil { + return nil, fmt.Errorf("browser/fill: %w", err) + } + return map[string]any{"session_state": envelope["session_state"]}, nil +} + +// BrowserClickConnector implements browser/click. +type BrowserClickConnector struct{} + +func (c *BrowserClickConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + selector, _ := params["selector"].(string) + if strings.TrimSpace(selector) == "" { + return nil, fmt.Errorf("browser/click: selector is required") + } + waitFor, _ := params["wait_for"].(string) + session, err := extractSession(params) + if err != nil { + return nil, fmt.Errorf("browser/click: %w", err) + } + var snippet strings.Builder + fmt.Fprintf(&snippet, "await page.click(%s);\n", mustJSONString(selector)) + if waitFor != "" { + fmt.Fprintf(&snippet, "await page.waitForSelector(%s);\n", mustJSONString(waitFor)) + } + script, err := buildDeclarativeScript(snippet.String(), session, extractTimeoutMs(params), false) + if err != nil { + return nil, fmt.Errorf("browser/click: %w", err) + } + envelope, err := executeBrowserScript(ctx, script, params) + if err != nil { + return nil, fmt.Errorf("browser/click: %w", err) + } + return map[string]any{"session_state": envelope["session_state"]}, nil +} diff --git a/packages/engine/internal/connector/browser_declarative_test.go b/packages/engine/internal/connector/browser_declarative_test.go new file mode 100644 index 0000000..f737b10 --- /dev/null +++ b/packages/engine/internal/connector/browser_declarative_test.go @@ -0,0 +1,634 @@ +package connector + +import ( + "context" + "fmt" + "strings" + "testing" +) + +func TestExtractSession_Absent(t *testing.T) { + s, err := extractSession(map[string]any{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s != nil { + t.Fatal("expected nil session for absent session_state") + } +} + +func TestExtractSession_Nil(t *testing.T) { + s, err := extractSession(map[string]any{"session_state": nil}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s != nil { + t.Fatal("expected nil session for nil session_state") + } +} + +func TestExtractSession_Valid(t *testing.T) { + params := map[string]any{ + "session_state": map[string]any{ + "cookies": []any{}, + "local_storage": map[string]any{}, + "url": "https://example.com/dashboard", + }, + } + s, err := extractSession(params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s == nil { + t.Fatal("expected non-nil session") + } + if s.URL != "https://example.com/dashboard" { + t.Errorf("URL = %q, want %q", s.URL, "https://example.com/dashboard") + } +} + +func TestExtractSession_Invalid(t *testing.T) { + _, err := extractSession(map[string]any{"session_state": "not-an-object"}) + if err == nil { + t.Fatal("expected error for invalid session_state") + } + if !strings.Contains(err.Error(), "invalid session_state") { + t.Errorf("error = %q, want 'invalid session_state'", err) + } +} + +func TestExtractTimeoutMs_Default(t *testing.T) { + if got := extractTimeoutMs(map[string]any{}); got != 30000 { + t.Errorf("got %d, want 30000", got) + } +} + +func TestExtractTimeoutMs_Provided(t *testing.T) { + if got := extractTimeoutMs(map[string]any{"timeout_ms": float64(5000)}); got != 5000 { + t.Errorf("got %d, want 5000", got) + } +} + +func TestBuildDeclarativeScript_FreshSession(t *testing.T) { + script, err := buildDeclarativeScript("actionData.x = 1;", nil, 30000, false) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } + for _, want := range []string{ + "chromium.launch({ headless: true })", + "actionData.x = 1;", + "setDefaultTimeout(30000)", + "session_state", + "JSON.stringify", + } { + if !strings.Contains(script, want) { + t.Errorf("script missing %q", want) + } + } + if strings.Contains(script, "addCookies") { + t.Error("fresh session should not call addCookies") + } + if strings.Contains(script, "page.goto") { + t.Error("fresh session should not restore URL") + } +} + +func TestBuildDeclarativeScript_WithSession_RestoresURL(t *testing.T) { + session := &BrowserSession{ + Cookies: []map[string]any{ + {"name": "sid", "value": "abc", "domain": "example.com", "path": "/"}, + }, + LocalStorage: map[string]map[string]string{}, + URL: "https://example.com/dashboard", + } + script, err := buildDeclarativeScript("", session, 5000, false) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } + if !strings.Contains(script, "addCookies") { + t.Error("should restore cookies") + } + if !strings.Contains(script, "example.com/dashboard") { + t.Error("should restore URL") + } + if !strings.Contains(script, "setDefaultTimeout(5000)") { + t.Error("should use provided timeout") + } +} + +func TestBuildDeclarativeScript_SkipURLRestore(t *testing.T) { + session := &BrowserSession{ + Cookies: []map[string]any{ + {"name": "sid", "value": "abc", "domain": "example.com", "path": "/"}, + }, + URL: "https://old.example.com/page", + } + script, err := buildDeclarativeScript("await page.goto('https://new.example.com');", session, 30000, true) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } + if strings.Contains(script, "old.example.com") { + t.Error("skipURLRestore=true must not navigate to previous URL") + } + if !strings.Contains(script, "new.example.com") { + t.Error("action snippet must be present") + } + if !strings.Contains(script, "addCookies") { + t.Error("skipURLRestore=true should still restore cookies") + } +} + +func TestBrowserNavigate_MissingURL(t *testing.T) { + c := &BrowserNavigateConnector{} + _, err := c.Execute(context.Background(), map[string]any{}) + if err == nil { + t.Fatal("expected error for missing url") + } + if !strings.Contains(err.Error(), "url is required") { + t.Errorf("error = %q, want 'url is required'", err) + } +} + +func TestBrowserNavigate_InvalidWaitUntil(t *testing.T) { + c := &BrowserNavigateConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "url": "https://example.com", + "wait_until": "forever", + }) + if err == nil { + t.Fatal("expected error for invalid wait_until") + } + if !strings.Contains(err.Error(), "wait_until") { + t.Errorf("error = %q, want 'wait_until' mention", err) + } +} + +func TestBrowserNavigate_InvalidSession(t *testing.T) { + c := &BrowserNavigateConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "url": "https://example.com", + "session_state": "not-a-map", + }) + if err == nil { + t.Fatal("expected error for invalid session_state") + } +} + +func TestBrowserClick_MissingSelector(t *testing.T) { + c := &BrowserClickConnector{} + _, err := c.Execute(context.Background(), map[string]any{}) + if err == nil { + t.Fatal("expected error for missing selector") + } + if !strings.Contains(err.Error(), "selector is required") { + t.Errorf("error = %q, want 'selector is required'", err) + } +} + +func TestBrowserClick_InvalidSession(t *testing.T) { + c := &BrowserClickConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "selector": "#btn", + "session_state": "bad", + }) + if err == nil { + t.Fatal("expected error for invalid session_state") + } +} + +func TestBrowserClick_ScriptContainsSelector(t *testing.T) { + snippet := fmt.Sprintf("await page.click(%s);\n", mustJSONString("#submit-btn")) + script, err := buildDeclarativeScript(snippet, nil, 30000, false) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } + if !strings.Contains(script, `page.click("#submit-btn")`) { + t.Error("script should contain page.click with the selector") + } +} + +func TestBrowserClick_ScriptContainsWaitFor(t *testing.T) { + snippet := fmt.Sprintf("await page.click(%s);\nawait page.waitForSelector(%s);\n", + mustJSONString("#btn"), mustJSONString(".result")) + script, err := buildDeclarativeScript(snippet, nil, 30000, false) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } + if !strings.Contains(script, `waitForSelector(".result")`) { + t.Error("script should contain waitForSelector") + } +} + +func TestBrowserFill_MissingFields(t *testing.T) { + c := &BrowserFillConnector{} + _, err := c.Execute(context.Background(), map[string]any{}) + if err == nil { + t.Fatal("expected error for missing fields") + } + if !strings.Contains(err.Error(), "fields is required") { + t.Errorf("error = %q, want 'fields is required'", err) + } +} + +func TestBrowserFill_EmptyFields(t *testing.T) { + c := &BrowserFillConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "fields": map[string]any{}, + }) + if err == nil { + t.Fatal("expected error for empty fields map") + } + if !strings.Contains(err.Error(), "non-empty") { + t.Errorf("error = %q, want 'non-empty'", err) + } +} + +func TestBrowserFill_InvalidSession(t *testing.T) { + c := &BrowserFillConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "fields": map[string]any{"#email": "test@example.com"}, + "session_state": 42, + }) + if err == nil { + t.Fatal("expected error for invalid session_state") + } +} + +func TestBrowserFill_ScriptContainsFillCalls(t *testing.T) { + snippet := fmt.Sprintf("await page.fill(%s, %s);\n", + mustJSONString("#email"), mustJSONString("user@example.com")) + script, err := buildDeclarativeScript(snippet, nil, 30000, false) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } + if !strings.Contains(script, `page.fill("#email", "user@example.com")`) { + t.Error("script should contain page.fill call") + } +} + +func TestBrowserExtract_MissingSelectors(t *testing.T) { + c := &BrowserExtractConnector{} + _, err := c.Execute(context.Background(), map[string]any{}) + if err == nil { + t.Fatal("expected error for missing selectors") + } + if !strings.Contains(err.Error(), "selectors is required") { + t.Errorf("error = %q, want 'selectors is required'", err) + } +} + +func TestBrowserExtract_EmptySelectors(t *testing.T) { + c := &BrowserExtractConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "selectors": map[string]any{}, + }) + if err == nil { + t.Fatal("expected error for empty selectors map") + } + if !strings.Contains(err.Error(), "non-empty") { + t.Errorf("error = %q, want 'non-empty'", err) + } +} + +func TestBrowserExtract_InvalidSession(t *testing.T) { + c := &BrowserExtractConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "selectors": map[string]any{"title": "h1"}, + "session_state": true, + }) + if err == nil { + t.Fatal("expected error for invalid session_state") + } +} + +func TestBrowserExtract_ScriptUsesTextContent(t *testing.T) { + snippet := fmt.Sprintf("actionData.data = {};\nactionData.data[%s] = await page.textContent(%s);\n", + mustJSONString("heading"), mustJSONString("h1")) + script, err := buildDeclarativeScript(snippet, nil, 30000, false) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } + if !strings.Contains(script, `page.textContent("h1")`) { + t.Error("script should use textContent for text extraction") + } +} + +func TestBrowserExtract_ScriptUsesGetAttribute(t *testing.T) { + snippet := fmt.Sprintf("actionData.data = {};\nactionData.data[%s] = await page.getAttribute(%s, %s);\n", + mustJSONString("link"), mustJSONString("a.btn"), mustJSONString("href")) + script, err := buildDeclarativeScript(snippet, nil, 30000, false) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } + if !strings.Contains(script, `page.getAttribute("a.btn", "href")`) { + t.Error("script should use getAttribute when attribute is set") + } +} + +func TestBrowserScreenshot_DefaultPath(t *testing.T) { + snippet := fmt.Sprintf( + "const buf = await page.screenshot({ fullPage: %v, path: undefined });\nactionData.base64 = buf.toString('base64');\nactionData.path = %s;\n", + false, mustJSONString("screenshot.png"), + ) + script, err := buildDeclarativeScript(snippet, nil, 30000, false) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } + if !strings.Contains(script, `"screenshot.png"`) { + t.Error("default path should be screenshot.png") + } +} + +func TestBrowserScreenshot_InvalidSession(t *testing.T) { + c := &BrowserScreenshotConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "session_state": []int{1, 2, 3}, + }) + if err == nil { + t.Fatal("expected error for invalid session_state") + } +} + +func TestBrowserScreenshot_FullPage(t *testing.T) { + snippet := fmt.Sprintf( + "const buf = await page.screenshot({ fullPage: %v, path: undefined });\nactionData.base64 = buf.toString('base64');\nactionData.path = %s;\n", + true, mustJSONString("screenshot.png"), + ) + script, err := buildDeclarativeScript(snippet, nil, 30000, false) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } + if !strings.Contains(script, "fullPage: true") { + t.Error("fullPage:true should appear in script") + } +} + +func TestBrowserWait_NoParams(t *testing.T) { + c := &BrowserWaitConnector{} + _, err := c.Execute(context.Background(), map[string]any{}) + if err == nil { + t.Fatal("expected error when no wait condition provided") + } + if !strings.Contains(err.Error(), "exactly one") { + t.Errorf("error = %q, want 'exactly one'", err) + } +} + +func TestBrowserWait_TwoParams(t *testing.T) { + c := &BrowserWaitConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "selector": ".foo", + "url_pattern": "https://*", + }) + if err == nil { + t.Fatal("expected error when two conditions provided") + } + if !strings.Contains(err.Error(), "exactly one") { + t.Errorf("error = %q, want 'exactly one'", err) + } +} + +func TestBrowserWait_SelectorScript(t *testing.T) { + snippet := fmt.Sprintf("await page.waitForSelector(%s);\n", mustJSONString(".ready")) + script, err := buildDeclarativeScript(snippet, nil, 30000, false) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } + if !strings.Contains(script, `waitForSelector(".ready")`) { + t.Error("script should contain waitForSelector") + } +} + +func TestBrowserWait_URLPatternScript(t *testing.T) { + snippet := fmt.Sprintf("await page.waitForURL(%s);\n", mustJSONString("https://example.com/**")) + script, err := buildDeclarativeScript(snippet, nil, 30000, false) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } + if !strings.Contains(script, `waitForURL("https://example.com/**")`) { + t.Error("script should contain waitForURL") + } +} + +func TestBrowserWait_DurationScript(t *testing.T) { + snippet := fmt.Sprintf("await page.waitForTimeout(%d);\n", 2000) + script, err := buildDeclarativeScript(snippet, nil, 30000, false) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } + if !strings.Contains(script, "waitForTimeout(2000)") { + t.Error("script should contain waitForTimeout") + } +} + +func TestBrowserWait_InvalidSession(t *testing.T) { + c := &BrowserWaitConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "selector": ".foo", + "session_state": 99, + }) + if err == nil { + t.Fatal("expected error for invalid session_state") + } +} + +func TestBrowserEvaluate_MissingExpression(t *testing.T) { + c := &BrowserEvaluateConnector{} + _, err := c.Execute(context.Background(), map[string]any{}) + if err == nil { + t.Fatal("expected error for missing expression") + } + if !strings.Contains(err.Error(), "expression is required") { + t.Errorf("error = %q, want 'expression is required'", err) + } +} + +func TestBrowserEvaluate_EmptyExpression(t *testing.T) { + c := &BrowserEvaluateConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "expression": " ", + }) + if err == nil { + t.Fatal("expected error for blank expression") + } +} + +func TestBrowserEvaluate_InvalidSession(t *testing.T) { + c := &BrowserEvaluateConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "expression": "document.title", + "session_state": false, + }) + if err == nil { + t.Fatal("expected error for invalid session_state") + } +} + +func TestBrowserEvaluate_ScriptContainsExpression(t *testing.T) { + snippet := "actionData.result = await page.evaluate(() => { return (document.title); });\n" + script, err := buildDeclarativeScript(snippet, nil, 30000, false) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } + if !strings.Contains(script, "document.title") { + t.Error("script should contain the expression") + } + if !strings.Contains(script, "page.evaluate") { + t.Error("script should use page.evaluate") + } +} + +// --------------------------------------------------------------------------- +// Integration tests — require Docker and network access +// --------------------------------------------------------------------------- + +func TestBrowserNavigate_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + dockerAvailableForBrowser(t) + + c := &BrowserNavigateConnector{} + out, err := c.Execute(context.Background(), map[string]any{ + "url": "https://example.com", + "pull": "missing", + }) + if err != nil { + t.Skipf("Execute failed (may need network/npm): %v", err) + } + title, _ := out["title"].(string) + if !strings.Contains(title, "Example Domain") { + t.Errorf("title = %q, want 'Example Domain'", title) + } + if out["session_state"] == nil { + t.Error("session_state should be present in output") + } +} + +func TestBrowserNavigateExtract_SessionChain(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + dockerAvailableForBrowser(t) + + // Step 1: navigate + nav := &BrowserNavigateConnector{} + navOut, err := nav.Execute(context.Background(), map[string]any{ + "url": "https://example.com", + "pull": "missing", + }) + if err != nil { + t.Skipf("navigate failed (may need network/npm): %v", err) + } + + // Step 2: extract using session from step 1 + ext := &BrowserExtractConnector{} + extOut, err := ext.Execute(context.Background(), map[string]any{ + "selectors": map[string]any{"heading": "h1"}, + "session_state": navOut["session_state"], + "pull": "missing", + }) + if err != nil { + t.Skipf("extract failed: %v", err) + } + data, ok := extOut["data"].(map[string]any) + if !ok { + t.Fatalf("data output is not a map: %T", extOut["data"]) + } + heading, _ := data["heading"].(string) + if !strings.Contains(heading, "Example Domain") { + t.Errorf("heading = %q, want 'Example Domain'", heading) + } +} + +func TestBrowserScreenshot_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + dockerAvailableForBrowser(t) + + nav := &BrowserNavigateConnector{} + navOut, err := nav.Execute(context.Background(), map[string]any{ + "url": "https://example.com", + "pull": "missing", + }) + if err != nil { + t.Skipf("navigate failed (may need network/npm): %v", err) + } + + ss := &BrowserScreenshotConnector{} + ssOut, err := ss.Execute(context.Background(), map[string]any{ + "session_state": navOut["session_state"], + "pull": "missing", + }) + if err != nil { + t.Skipf("screenshot failed: %v", err) + } + b64, _ := ssOut["base64"].(string) + if len(b64) == 0 { + t.Error("expected non-empty base64 screenshot") + } + if ssOut["path"] != "screenshot.png" { + t.Errorf("path = %v, want screenshot.png", ssOut["path"]) + } +} + +func TestBrowserEvaluate_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + dockerAvailableForBrowser(t) + + nav := &BrowserNavigateConnector{} + navOut, err := nav.Execute(context.Background(), map[string]any{ + "url": "https://example.com", + "pull": "missing", + }) + if err != nil { + t.Skipf("navigate failed (may need network/npm): %v", err) + } + + ev := &BrowserEvaluateConnector{} + evOut, err := ev.Execute(context.Background(), map[string]any{ + "expression": "document.location.hostname", + "session_state": navOut["session_state"], + "pull": "missing", + }) + if err != nil { + t.Skipf("evaluate failed: %v", err) + } + result, _ := evOut["result"].(string) + if result != "example.com" { + t.Errorf("result = %q, want %q", result, "example.com") + } +} + +func TestBrowserWait_TimeoutError_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + dockerAvailableForBrowser(t) + + nav := &BrowserNavigateConnector{} + navOut, err := nav.Execute(context.Background(), map[string]any{ + "url": "https://example.com", + "pull": "missing", + }) + if err != nil { + t.Skipf("navigate failed (may need network/npm): %v", err) + } + + w := &BrowserWaitConnector{} + _, err = w.Execute(context.Background(), map[string]any{ + "selector": ".this-selector-does-not-exist", + "session_state": navOut["session_state"], + "timeout_ms": float64(2000), + "pull": "missing", + }) + if err == nil { + t.Fatal("expected error when selector not found within timeout") + } + if !strings.Contains(err.Error(), "browser/wait") { + t.Errorf("error = %q, want 'browser/wait' prefix", err) + } +} diff --git a/packages/engine/internal/connector/connector.go b/packages/engine/internal/connector/connector.go index a907d0a..75a69c9 100644 --- a/packages/engine/internal/connector/connector.go +++ b/packages/engine/internal/connector/connector.go @@ -40,6 +40,13 @@ func NewRegistry() *Registry { r.Register("s3/list", &S3ListConnector{}) r.Register("docker/run", &DockerRunConnector{}) r.Register("browser/run", &BrowserRunConnector{}) + r.Register("browser/navigate", &BrowserNavigateConnector{}) + r.Register("browser/click", &BrowserClickConnector{}) + r.Register("browser/fill", &BrowserFillConnector{}) + r.Register("browser/extract", &BrowserExtractConnector{}) + r.Register("browser/screenshot", &BrowserScreenshotConnector{}) + r.Register("browser/wait", &BrowserWaitConnector{}) + r.Register("browser/evaluate", &BrowserEvaluateConnector{}) r.Register("github/create_issue", &GitHubCreateIssueConnector{}) r.Register("github/dispatch", &GitHubDispatchConnector{}) r.Register("github/dispatch_workflow", &GitHubDispatchWorkflowConnector{})