From 353c9c958b9dd5d55bc53f72ddb8fff2ff18e819 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Mon, 1 Jun 2026 22:57:45 -0400 Subject: [PATCH 01/13] feat(browser): BrowserSession type and session helper functions --- .../internal/connector/browser_declarative.go | 46 +++++++++++++ .../connector/browser_declarative_test.go | 68 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 packages/engine/internal/connector/browser_declarative.go create mode 100644 packages/engine/internal/connector/browser_declarative_test.go diff --git a/packages/engine/internal/connector/browser_declarative.go b/packages/engine/internal/connector/browser_declarative.go new file mode 100644 index 0000000..d9837f5 --- /dev/null +++ b/packages/engine/internal/connector/browser_declarative.go @@ -0,0 +1,46 @@ +package connector + +import ( + "encoding/json" + "fmt" +) + +// 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 +} + +// 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 +} 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..542b2af --- /dev/null +++ b/packages/engine/internal/connector/browser_declarative_test.go @@ -0,0 +1,68 @@ +package connector + +import ( + "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) + } +} From 0fee4a6d291b678664470026b97138254bb512a0 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Mon, 1 Jun 2026 23:01:08 -0400 Subject: [PATCH 02/13] feat(browser): shared declarative script generator Implements buildDeclarativeScript, which emits a self-contained Playwright JS script supporting fresh-session launch, cookie+localStorage restore, and selective URL-skip for the navigate connector. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/connector/browser_declarative.go | 66 +++++++++++++++++++ .../connector/browser_declarative_test.go | 55 ++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/packages/engine/internal/connector/browser_declarative.go b/packages/engine/internal/connector/browser_declarative.go index d9837f5..3b9aa65 100644 --- a/packages/engine/internal/connector/browser_declarative.go +++ b/packages/engine/internal/connector/browser_declarative.go @@ -3,6 +3,7 @@ package connector import ( "encoding/json" "fmt" + "strings" ) // BrowserSession holds serialized browser state passed between declarative steps. @@ -30,6 +31,71 @@ func extractSession(params map[string]any) (*BrowserSession, error) { 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 { + 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();\n") + b.WriteString(" try {\n") + b.WriteString(" const context = await browser.newContext();\n") + + if session != nil && len(session.Cookies) > 0 { + cookiesJSON, _ := json.Marshal(session.Cookies) + 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 { + fmt.Fprintf(&b, " await page.goto(%q);\n", session.URL) + if len(session.LocalStorage) > 0 { + lsJSON, _ := json.Marshal(session.LocalStorage) + 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() +} + // 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 { diff --git a/packages/engine/internal/connector/browser_declarative_test.go b/packages/engine/internal/connector/browser_declarative_test.go index 542b2af..3936bbb 100644 --- a/packages/engine/internal/connector/browser_declarative_test.go +++ b/packages/engine/internal/connector/browser_declarative_test.go @@ -66,3 +66,58 @@ func TestExtractTimeoutMs_Provided(t *testing.T) { t.Errorf("got %d, want 5000", got) } } + +func TestBuildDeclarativeScript_FreshSession(t *testing.T) { + script := buildDeclarativeScript("actionData.x = 1;", nil, 30000, false) + for _, want := range []string{ + "chromium.launch()", + "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 := buildDeclarativeScript("", session, 5000, false) + 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{}, + URL: "https://old.example.com/page", + } + script := buildDeclarativeScript("await page.goto('https://new.example.com');", session, 30000, true) + 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") + } +} From d5d5f45d73c807453382a21210b10428935a8775 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Mon, 1 Jun 2026 23:02:59 -0400 Subject: [PATCH 03/13] fix(browser): explicit headless launch and cookie restore test for skipURLRestore - Change chromium.launch() to chromium.launch({ headless: true }) per spec - Update TestBuildDeclarativeScript_FreshSession to assert the explicit headless flag - Update TestBuildDeclarativeScript_SkipURLRestore to include a real cookie and assert addCookies is emitted even when URL restore is skipped Co-Authored-By: Claude Sonnet 4.6 --- .../engine/internal/connector/browser_declarative.go | 2 +- .../internal/connector/browser_declarative_test.go | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/engine/internal/connector/browser_declarative.go b/packages/engine/internal/connector/browser_declarative.go index 3b9aa65..2acd257 100644 --- a/packages/engine/internal/connector/browser_declarative.go +++ b/packages/engine/internal/connector/browser_declarative.go @@ -42,7 +42,7 @@ func buildDeclarativeScript(actionSnippet string, session *BrowserSession, timeo b.WriteString("const { chromium } = require('playwright');\n") b.WriteString("(async () => {\n") b.WriteString(" let actionData = {};\n") - b.WriteString(" const browser = await chromium.launch();\n") + b.WriteString(" const browser = await chromium.launch({ headless: true });\n") b.WriteString(" try {\n") b.WriteString(" const context = await browser.newContext();\n") diff --git a/packages/engine/internal/connector/browser_declarative_test.go b/packages/engine/internal/connector/browser_declarative_test.go index 3936bbb..27639b2 100644 --- a/packages/engine/internal/connector/browser_declarative_test.go +++ b/packages/engine/internal/connector/browser_declarative_test.go @@ -70,7 +70,7 @@ func TestExtractTimeoutMs_Provided(t *testing.T) { func TestBuildDeclarativeScript_FreshSession(t *testing.T) { script := buildDeclarativeScript("actionData.x = 1;", nil, 30000, false) for _, want := range []string{ - "chromium.launch()", + "chromium.launch({ headless: true })", "actionData.x = 1;", "setDefaultTimeout(30000)", "session_state", @@ -110,8 +110,10 @@ func TestBuildDeclarativeScript_WithSession_RestoresURL(t *testing.T) { func TestBuildDeclarativeScript_SkipURLRestore(t *testing.T) { session := &BrowserSession{ - Cookies: []map[string]any{}, - URL: "https://old.example.com/page", + Cookies: []map[string]any{ + {"name": "sid", "value": "abc", "domain": "example.com", "path": "/"}, + }, + URL: "https://old.example.com/page", } script := buildDeclarativeScript("await page.goto('https://new.example.com');", session, 30000, true) if strings.Contains(script, "old.example.com") { @@ -120,4 +122,7 @@ func TestBuildDeclarativeScript_SkipURLRestore(t *testing.T) { 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") + } } From e5e3b0ec19719b1c605adcd8244491b998f6d38b Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Mon, 1 Jun 2026 23:06:00 -0400 Subject: [PATCH 04/13] fix(browser): propagate json.Marshal errors, use json.Marshal for URL quoting, document localStorage redirect limitation Co-Authored-By: Claude Sonnet 4.6 --- .../internal/connector/browser_declarative.go | 25 +++++++++++++++---- .../connector/browser_declarative_test.go | 15 ++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/engine/internal/connector/browser_declarative.go b/packages/engine/internal/connector/browser_declarative.go index 2acd257..a86dde6 100644 --- a/packages/engine/internal/connector/browser_declarative.go +++ b/packages/engine/internal/connector/browser_declarative.go @@ -36,7 +36,7 @@ func extractSession(params map[string]any) (*BrowserSession, error) { // 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 { +func buildDeclarativeScript(actionSnippet string, session *BrowserSession, timeoutMs int, skipURLRestore bool) (string, error) { var b strings.Builder b.WriteString("const { chromium } = require('playwright');\n") @@ -47,7 +47,10 @@ func buildDeclarativeScript(actionSnippet string, session *BrowserSession, timeo b.WriteString(" const context = await browser.newContext();\n") if session != nil && len(session.Cookies) > 0 { - cookiesJSON, _ := json.Marshal(session.Cookies) + 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) } @@ -55,9 +58,21 @@ func buildDeclarativeScript(actionSnippet string, session *BrowserSession, timeo fmt.Fprintf(&b, " page.setDefaultTimeout(%d);\n", timeoutMs) if session != nil && session.URL != "" && !skipURLRestore { - fmt.Fprintf(&b, " await page.goto(%q);\n", session.URL) + 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, _ := json.Marshal(session.LocalStorage) + 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") @@ -93,7 +108,7 @@ func buildDeclarativeScript(actionSnippet string, session *BrowserSession, timeo b.WriteString(" }\n") b.WriteString("})().catch(err => { process.stderr.write(err.message + '\\n'); process.exit(1); });\n") - return b.String() + return b.String(), nil } // extractTimeoutMs returns the timeout_ms param as an int, defaulting to 30000. diff --git a/packages/engine/internal/connector/browser_declarative_test.go b/packages/engine/internal/connector/browser_declarative_test.go index 27639b2..63cd058 100644 --- a/packages/engine/internal/connector/browser_declarative_test.go +++ b/packages/engine/internal/connector/browser_declarative_test.go @@ -68,7 +68,10 @@ func TestExtractTimeoutMs_Provided(t *testing.T) { } func TestBuildDeclarativeScript_FreshSession(t *testing.T) { - script := buildDeclarativeScript("actionData.x = 1;", nil, 30000, false) + 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;", @@ -96,7 +99,10 @@ func TestBuildDeclarativeScript_WithSession_RestoresURL(t *testing.T) { LocalStorage: map[string]map[string]string{}, URL: "https://example.com/dashboard", } - script := buildDeclarativeScript("", session, 5000, false) + script, err := buildDeclarativeScript("", session, 5000, false) + if err != nil { + t.Fatalf("buildDeclarativeScript: %v", err) + } if !strings.Contains(script, "addCookies") { t.Error("should restore cookies") } @@ -115,7 +121,10 @@ func TestBuildDeclarativeScript_SkipURLRestore(t *testing.T) { }, URL: "https://old.example.com/page", } - script := buildDeclarativeScript("await page.goto('https://new.example.com');", session, 30000, true) + 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") } From 0f3a8649afe8da4ba8e518647d74d33b3e4ba5cc Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Mon, 1 Jun 2026 23:07:45 -0400 Subject: [PATCH 05/13] feat(browser): executeBrowserScript execution helper --- .../internal/connector/browser_declarative.go | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/engine/internal/connector/browser_declarative.go b/packages/engine/internal/connector/browser_declarative.go index a86dde6..f311623 100644 --- a/packages/engine/internal/connector/browser_declarative.go +++ b/packages/engine/internal/connector/browser_declarative.go @@ -1,6 +1,7 @@ package connector import ( + "context" "encoding/json" "fmt" "strings" @@ -125,3 +126,48 @@ func extractTimeoutMs(params map[string]any) int { } 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 +} From a27c87c1a1922f96e8f5cb05d3ad47a963b3aa4a Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 2 Jun 2026 14:31:01 -0400 Subject: [PATCH 06/13] feat(browser): browser/navigate connector --- .../internal/connector/browser_declarative.go | 49 +++++++++++++++++++ .../connector/browser_declarative_test.go | 37 ++++++++++++++ .../engine/internal/connector/connector.go | 1 + 3 files changed, 87 insertions(+) diff --git a/packages/engine/internal/connector/browser_declarative.go b/packages/engine/internal/connector/browser_declarative.go index f311623..8f9366c 100644 --- a/packages/engine/internal/connector/browser_declarative.go +++ b/packages/engine/internal/connector/browser_declarative.go @@ -171,3 +171,52 @@ func executeBrowserScript(ctx context.Context, script string, params map[string] } 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 +} diff --git a/packages/engine/internal/connector/browser_declarative_test.go b/packages/engine/internal/connector/browser_declarative_test.go index 63cd058..a9d236e 100644 --- a/packages/engine/internal/connector/browser_declarative_test.go +++ b/packages/engine/internal/connector/browser_declarative_test.go @@ -1,6 +1,7 @@ package connector import ( + "context" "strings" "testing" ) @@ -135,3 +136,39 @@ func TestBuildDeclarativeScript_SkipURLRestore(t *testing.T) { 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") + } +} diff --git a/packages/engine/internal/connector/connector.go b/packages/engine/internal/connector/connector.go index a907d0a..73b24f7 100644 --- a/packages/engine/internal/connector/connector.go +++ b/packages/engine/internal/connector/connector.go @@ -40,6 +40,7 @@ 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("github/create_issue", &GitHubCreateIssueConnector{}) r.Register("github/dispatch", &GitHubDispatchConnector{}) r.Register("github/dispatch_workflow", &GitHubDispatchWorkflowConnector{}) From 875420c3b618b9073562b4ae144cacd80a168f41 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 2 Jun 2026 14:33:16 -0400 Subject: [PATCH 07/13] feat(browser): browser/click connector --- .../internal/connector/browser_declarative.go | 29 ++++++++++++ .../connector/browser_declarative_test.go | 46 +++++++++++++++++++ .../engine/internal/connector/connector.go | 1 + 3 files changed, 76 insertions(+) diff --git a/packages/engine/internal/connector/browser_declarative.go b/packages/engine/internal/connector/browser_declarative.go index 8f9366c..033117d 100644 --- a/packages/engine/internal/connector/browser_declarative.go +++ b/packages/engine/internal/connector/browser_declarative.go @@ -220,3 +220,32 @@ func (c *BrowserNavigateConnector) Execute(ctx context.Context, params map[strin } return out, 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 index a9d236e..ac9dcb8 100644 --- a/packages/engine/internal/connector/browser_declarative_test.go +++ b/packages/engine/internal/connector/browser_declarative_test.go @@ -2,6 +2,7 @@ package connector import ( "context" + "fmt" "strings" "testing" ) @@ -172,3 +173,48 @@ func TestBrowserNavigate_InvalidSession(t *testing.T) { 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") + } +} diff --git a/packages/engine/internal/connector/connector.go b/packages/engine/internal/connector/connector.go index 73b24f7..363ea46 100644 --- a/packages/engine/internal/connector/connector.go +++ b/packages/engine/internal/connector/connector.go @@ -41,6 +41,7 @@ func NewRegistry() *Registry { r.Register("docker/run", &DockerRunConnector{}) r.Register("browser/run", &BrowserRunConnector{}) r.Register("browser/navigate", &BrowserNavigateConnector{}) + r.Register("browser/click", &BrowserClickConnector{}) r.Register("github/create_issue", &GitHubCreateIssueConnector{}) r.Register("github/dispatch", &GitHubDispatchConnector{}) r.Register("github/dispatch_workflow", &GitHubDispatchWorkflowConnector{}) From 3b55868fe0abee3a6c97ecb5d5e6d4006ebb07b8 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 2 Jun 2026 14:36:48 -0400 Subject: [PATCH 08/13] feat(browser): browser/fill connector --- .../internal/connector/browser_declarative.go | 32 +++++++++++++ .../connector/browser_declarative_test.go | 47 +++++++++++++++++++ .../engine/internal/connector/connector.go | 1 + 3 files changed, 80 insertions(+) diff --git a/packages/engine/internal/connector/browser_declarative.go b/packages/engine/internal/connector/browser_declarative.go index 033117d..7b0a705 100644 --- a/packages/engine/internal/connector/browser_declarative.go +++ b/packages/engine/internal/connector/browser_declarative.go @@ -221,6 +221,38 @@ func (c *BrowserNavigateConnector) Execute(ctx context.Context, params map[strin 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{} diff --git a/packages/engine/internal/connector/browser_declarative_test.go b/packages/engine/internal/connector/browser_declarative_test.go index ac9dcb8..cc3f8ca 100644 --- a/packages/engine/internal/connector/browser_declarative_test.go +++ b/packages/engine/internal/connector/browser_declarative_test.go @@ -218,3 +218,50 @@ func TestBrowserClick_ScriptContainsWaitFor(t *testing.T) { 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") + } +} diff --git a/packages/engine/internal/connector/connector.go b/packages/engine/internal/connector/connector.go index 363ea46..c6fe895 100644 --- a/packages/engine/internal/connector/connector.go +++ b/packages/engine/internal/connector/connector.go @@ -42,6 +42,7 @@ func NewRegistry() *Registry { r.Register("browser/run", &BrowserRunConnector{}) r.Register("browser/navigate", &BrowserNavigateConnector{}) r.Register("browser/click", &BrowserClickConnector{}) + r.Register("browser/fill", &BrowserFillConnector{}) r.Register("github/create_issue", &GitHubCreateIssueConnector{}) r.Register("github/dispatch", &GitHubDispatchConnector{}) r.Register("github/dispatch_workflow", &GitHubDispatchWorkflowConnector{}) From afab4a3c72b3fb834d72b9465ee43f5cf6c6e060 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 2 Jun 2026 14:37:34 -0400 Subject: [PATCH 09/13] feat(browser): browser/extract connector --- .../internal/connector/browser_declarative.go | 46 +++++++++++++++ .../connector/browser_declarative_test.go | 59 +++++++++++++++++++ .../engine/internal/connector/connector.go | 1 + 3 files changed, 106 insertions(+) diff --git a/packages/engine/internal/connector/browser_declarative.go b/packages/engine/internal/connector/browser_declarative.go index 7b0a705..c3eb0a2 100644 --- a/packages/engine/internal/connector/browser_declarative.go +++ b/packages/engine/internal/connector/browser_declarative.go @@ -221,6 +221,52 @@ func (c *BrowserNavigateConnector) Execute(ctx context.Context, params map[strin 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{} diff --git a/packages/engine/internal/connector/browser_declarative_test.go b/packages/engine/internal/connector/browser_declarative_test.go index cc3f8ca..758554b 100644 --- a/packages/engine/internal/connector/browser_declarative_test.go +++ b/packages/engine/internal/connector/browser_declarative_test.go @@ -265,3 +265,62 @@ func TestBrowserFill_ScriptContainsFillCalls(t *testing.T) { 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") + } +} diff --git a/packages/engine/internal/connector/connector.go b/packages/engine/internal/connector/connector.go index c6fe895..9dd80e1 100644 --- a/packages/engine/internal/connector/connector.go +++ b/packages/engine/internal/connector/connector.go @@ -43,6 +43,7 @@ func NewRegistry() *Registry { r.Register("browser/navigate", &BrowserNavigateConnector{}) r.Register("browser/click", &BrowserClickConnector{}) r.Register("browser/fill", &BrowserFillConnector{}) + r.Register("browser/extract", &BrowserExtractConnector{}) r.Register("github/create_issue", &GitHubCreateIssueConnector{}) r.Register("github/dispatch", &GitHubDispatchConnector{}) r.Register("github/dispatch_workflow", &GitHubDispatchWorkflowConnector{}) From cf8c888d91306a332a85995f42ebfcd223ca5a22 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 2 Jun 2026 14:38:26 -0400 Subject: [PATCH 10/13] feat(browser): browser/screenshot connector --- .../internal/connector/browser_declarative.go | 37 ++++++++++++++++++ .../connector/browser_declarative_test.go | 38 +++++++++++++++++++ .../engine/internal/connector/connector.go | 1 + 3 files changed, 76 insertions(+) diff --git a/packages/engine/internal/connector/browser_declarative.go b/packages/engine/internal/connector/browser_declarative.go index c3eb0a2..1ea6389 100644 --- a/packages/engine/internal/connector/browser_declarative.go +++ b/packages/engine/internal/connector/browser_declarative.go @@ -221,6 +221,43 @@ func (c *BrowserNavigateConnector) Execute(ctx context.Context, params map[strin return out, 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{} diff --git a/packages/engine/internal/connector/browser_declarative_test.go b/packages/engine/internal/connector/browser_declarative_test.go index 758554b..cc138f6 100644 --- a/packages/engine/internal/connector/browser_declarative_test.go +++ b/packages/engine/internal/connector/browser_declarative_test.go @@ -324,3 +324,41 @@ func TestBrowserExtract_ScriptUsesGetAttribute(t *testing.T) { 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") + } +} diff --git a/packages/engine/internal/connector/connector.go b/packages/engine/internal/connector/connector.go index 9dd80e1..10d07af 100644 --- a/packages/engine/internal/connector/connector.go +++ b/packages/engine/internal/connector/connector.go @@ -44,6 +44,7 @@ func NewRegistry() *Registry { r.Register("browser/click", &BrowserClickConnector{}) r.Register("browser/fill", &BrowserFillConnector{}) r.Register("browser/extract", &BrowserExtractConnector{}) + r.Register("browser/screenshot", &BrowserScreenshotConnector{}) r.Register("github/create_issue", &GitHubCreateIssueConnector{}) r.Register("github/dispatch", &GitHubDispatchConnector{}) r.Register("github/dispatch_workflow", &GitHubDispatchWorkflowConnector{}) From 8642ebb36cad51b2b3a9106cfbcdbdf817ba76e9 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 2 Jun 2026 14:39:20 -0400 Subject: [PATCH 11/13] feat(browser): browser/wait connector --- .../internal/connector/browser_declarative.go | 58 ++++++++++++++++ .../connector/browser_declarative_test.go | 69 +++++++++++++++++++ .../engine/internal/connector/connector.go | 1 + 3 files changed, 128 insertions(+) diff --git a/packages/engine/internal/connector/browser_declarative.go b/packages/engine/internal/connector/browser_declarative.go index 1ea6389..7439102 100644 --- a/packages/engine/internal/connector/browser_declarative.go +++ b/packages/engine/internal/connector/browser_declarative.go @@ -221,6 +221,64 @@ func (c *BrowserNavigateConnector) Execute(ctx context.Context, params map[strin 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{} diff --git a/packages/engine/internal/connector/browser_declarative_test.go b/packages/engine/internal/connector/browser_declarative_test.go index cc138f6..398eaa2 100644 --- a/packages/engine/internal/connector/browser_declarative_test.go +++ b/packages/engine/internal/connector/browser_declarative_test.go @@ -362,3 +362,72 @@ func TestBrowserScreenshot_FullPage(t *testing.T) { 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") + } +} diff --git a/packages/engine/internal/connector/connector.go b/packages/engine/internal/connector/connector.go index 10d07af..f77cf1c 100644 --- a/packages/engine/internal/connector/connector.go +++ b/packages/engine/internal/connector/connector.go @@ -45,6 +45,7 @@ func NewRegistry() *Registry { r.Register("browser/fill", &BrowserFillConnector{}) r.Register("browser/extract", &BrowserExtractConnector{}) r.Register("browser/screenshot", &BrowserScreenshotConnector{}) + r.Register("browser/wait", &BrowserWaitConnector{}) r.Register("github/create_issue", &GitHubCreateIssueConnector{}) r.Register("github/dispatch", &GitHubDispatchConnector{}) r.Register("github/dispatch_workflow", &GitHubDispatchWorkflowConnector{}) From e3b32f6eefd3c8a69da5f9f90755492ee77d5a91 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 2 Jun 2026 14:40:33 -0400 Subject: [PATCH 12/13] =?UTF-8?q?feat(browser):=20browser/evaluate=20conne?= =?UTF-8?q?ctor=20=E2=80=94=20completes=20declarative=20action=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/connector/browser_declarative.go | 31 +++++++++++++ .../connector/browser_declarative_test.go | 46 +++++++++++++++++++ .../engine/internal/connector/connector.go | 1 + 3 files changed, 78 insertions(+) diff --git a/packages/engine/internal/connector/browser_declarative.go b/packages/engine/internal/connector/browser_declarative.go index 7439102..4698c16 100644 --- a/packages/engine/internal/connector/browser_declarative.go +++ b/packages/engine/internal/connector/browser_declarative.go @@ -221,6 +221,37 @@ func (c *BrowserNavigateConnector) Execute(ctx context.Context, params map[strin 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{} diff --git a/packages/engine/internal/connector/browser_declarative_test.go b/packages/engine/internal/connector/browser_declarative_test.go index 398eaa2..b40550e 100644 --- a/packages/engine/internal/connector/browser_declarative_test.go +++ b/packages/engine/internal/connector/browser_declarative_test.go @@ -431,3 +431,49 @@ func TestBrowserWait_InvalidSession(t *testing.T) { 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") + } +} diff --git a/packages/engine/internal/connector/connector.go b/packages/engine/internal/connector/connector.go index f77cf1c..75a69c9 100644 --- a/packages/engine/internal/connector/connector.go +++ b/packages/engine/internal/connector/connector.go @@ -46,6 +46,7 @@ func NewRegistry() *Registry { 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{}) From 832ce055c4c83fb3577fe299f360d9927a4d3ad4 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Tue, 2 Jun 2026 14:43:40 -0400 Subject: [PATCH 13/13] test(browser): integration tests for declarative browser connectors --- .../connector/browser_declarative_test.go | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/packages/engine/internal/connector/browser_declarative_test.go b/packages/engine/internal/connector/browser_declarative_test.go index b40550e..f737b10 100644 --- a/packages/engine/internal/connector/browser_declarative_test.go +++ b/packages/engine/internal/connector/browser_declarative_test.go @@ -477,3 +477,158 @@ func TestBrowserEvaluate_ScriptContainsExpression(t *testing.T) { 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) + } +}