diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38eca25..3349877 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,4 +108,35 @@ jobs: - name: Cache cargo artifacts uses: Swatinem/rust-cache@v2 - name: Validate crates.io package - run: cargo publish --dry-run --locked --no-verify + shell: bash + run: | + set -euo pipefail + + # Publish in dependency order matching release.yml. + # - Skip crates that already exist on crates.io (idempotent). + # - Skip crates whose workspace dependencies haven't been + # published yet (valid for newly-added protocol crates before + # their first release). + CRATES=( + earl-core + earl-protocol-http + earl-protocol-grpc + earl-protocol-bash + earl-protocol-sql + earl-protocol-browser + earl + ) + + for crate in "${CRATES[@]}"; do + echo "Dry-run for $crate..." + output=$(cargo publish --dry-run --locked --no-verify -p "$crate" 2>&1) && echo "$output" || { + if echo "$output" | grep -q "already exists on crates.io index"; then + echo "$crate already on crates.io, skipping." + elif echo "$output" | grep -q "no matching package named"; then + echo "$crate has unpublished workspace dependencies, skipping." + else + echo "$output" >&2 + exit 1 + fi + } + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 088ecfd..15161e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -272,6 +272,7 @@ jobs: earl-protocol-grpc earl-protocol-bash earl-protocol-sql + earl-protocol-browser earl ) diff --git a/Cargo.lock b/Cargo.lock index e41278e..31e99cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,7 +277,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -318,7 +318,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -344,7 +344,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -367,6 +367,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async-tungstenite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc405d38be14342132609f06f02acaf825ddccfe76c4824a69281e0458ebd4" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tokio", + "tungstenite", +] + [[package]] name = "atoi" version = "2.0.0" @@ -1107,6 +1124,71 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chromiumoxide" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dba63f489631c074314186b74fd095f0c3a3f4d0d587a4d8f64e789cfc52c10b" +dependencies = [ + "async-tungstenite", + "base64 0.22.1", + "bytes", + "chromiumoxide_cdp", + "chromiumoxide_types", + "dunce", + "fnv", + "futures", + "futures-timer", + "pin-project-lite", + "reqwest 0.13.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "which 8.0.0", + "windows-registry", +] + +[[package]] +name = "chromiumoxide_cdp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf69ee9be9c6702b09751f11aa27f050de0895beb897f4aef2f67b491175c68" +dependencies = [ + "chromiumoxide_pdl", + "chromiumoxide_types", + "serde", + "serde_json", +] + +[[package]] +name = "chromiumoxide_pdl" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c984bb7a8110739a256c66baa698faf5e59f5a27de0094ef8e3ffca1cbb25e70" +dependencies = [ + "chromiumoxide_types", + "either", + "heck", + "once_cell", + "proc-macro2", + "quote", + "regex", + "serde_json", +] + +[[package]] +name = "chromiumoxide_types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeeb06dad4418c2e0b1595a33fbd0645a057ed9a2a8d1eee28cc126d4910b921" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "chrono" version = "0.4.43" @@ -1412,7 +1494,7 @@ dependencies = [ "crossterm_winapi", "document-features", "parking_lot", - "rustix", + "rustix 1.1.4", "winapi", ] @@ -1794,6 +1876,7 @@ dependencies = [ "directories", "earl-core", "earl-protocol-bash", + "earl-protocol-browser", "earl-protocol-grpc", "earl-protocol-http", "earl-protocol-sql", @@ -1863,6 +1946,29 @@ dependencies = [ "tokio", ] +[[package]] +name = "earl-protocol-browser" +version = "0.5.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chromiumoxide", + "chrono", + "directories", + "earl-core", + "fs4", + "futures", + "libc", + "rkyv", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "which 7.0.3", +] + [[package]] name = "earl-protocol-grpc" version = "0.5.0" @@ -1983,6 +2089,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equator" version = "0.4.2" @@ -2193,6 +2305,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c29c30684418547d476f0b48e84f4821639119c483b1eccd566c8cd0cd05f521" +dependencies = [ + "rustix 0.38.44", + "tokio", + "windows-sys 0.52.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2209,6 +2332,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -2301,6 +2439,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -3365,6 +3504,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4265,7 +4410,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5024,6 +5169,19 @@ dependencies = [ "synstructure 0.12.6", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -5033,7 +5191,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -5978,7 +6136,7 @@ dependencies = [ "fastrand", "getrandom 0.4.1", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -6484,6 +6642,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typed-arena" version = "1.7.0" @@ -6986,6 +7161,29 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix 1.1.4", + "winsafe", +] + +[[package]] +name = "which" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +dependencies = [ + "env_home", + "rustix 1.1.4", + "winsafe", +] + [[package]] name = "whoami" version = "1.6.1" @@ -7419,6 +7617,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 0cc7260..360fae9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,7 @@ include_dir = "0.7" earl-protocol-http = { version = "0.5.0", path = "crates/earl-protocol-http", optional = true } earl-protocol-bash = { version = "0.5.0", path = "crates/earl-protocol-bash", optional = true } earl-protocol-sql = { version = "0.5.0", path = "crates/earl-protocol-sql", optional = true } +earl-protocol-browser = { version = "0.5.0", path = "crates/earl-protocol-browser", optional = true } vaultrs = { version = "0.7", optional = true } aws-config = { version = "1", optional = true } aws-sdk-secretsmanager = { version = "1", optional = true } @@ -110,13 +111,14 @@ tower = { version = "0.5", features = ["util"] } openssl = { version = "0.10", features = ["vendored"] } [features] -default = ["http", "graphql", "grpc", "bash", "sql", "local-search"] +default = ["http", "graphql", "grpc", "bash", "sql", "browser", "local-search"] local-search = ["dep:fastembed"] http = ["dep:earl-protocol-http"] graphql = ["http"] grpc = ["dep:earl-protocol-grpc"] bash = ["dep:earl-protocol-bash"] sql = ["dep:earl-protocol-sql"] +browser = ["dep:earl-protocol-browser"] secrets-1password = [] secrets-vault = ["dep:vaultrs"] secrets-aws = ["dep:aws-config", "dep:aws-sdk-secretsmanager"] diff --git a/crates/earl-protocol-browser/Cargo.toml b/crates/earl-protocol-browser/Cargo.toml new file mode 100644 index 0000000..38f9f7c --- /dev/null +++ b/crates/earl-protocol-browser/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "earl-protocol-browser" +version = "0.5.0" +edition.workspace = true +description = "Browser automation protocol for earl (chromiumoxide)" +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true + +[dependencies] +anyhow = "1.0" +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +chromiumoxide = { version = "0.9", default-features = false, features = ["bytes"] } +directories = "6.0" +earl-core = { version = "0.5.0", path = "../earl-core" } +fs4 = { version = "0.12", features = ["tokio"] } +futures = "0.3" +rkyv = { version = "0.8", features = ["std", "bytecheck"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tempfile = "3.19" +thiserror = "2.0" +tokio = { version = "1", features = ["rt-multi-thread", "time", "sync", "io-util"] } +tracing = "0.1" +which = "7.0" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "time", "sync", "io-util", "net", "macros", "test-util"] } diff --git a/crates/earl-protocol-browser/src/accessibility.rs b/crates/earl-protocol-browser/src/accessibility.rs new file mode 100644 index 0000000..c695f0a --- /dev/null +++ b/crates/earl-protocol-browser/src/accessibility.rs @@ -0,0 +1,135 @@ +use std::collections::HashMap; + +/// Simplified AX node populated from CDP Accessibility.getFullAXTree. +#[derive(Debug, Clone)] +pub struct AXNode { + pub backend_node_id: u64, + pub role: String, + pub name: String, + pub children: Vec, +} + +/// Render AX tree to markdown with opaque refs. +/// Returns (markdown_text, ref_id → backend_node_id map). +/// ref_id format: "e{backend_node_id}" +pub fn render_ax_tree(nodes: &[AXNode], max_nodes: usize) -> (String, HashMap) { + let mut buf = String::new(); + let mut refs: HashMap = HashMap::new(); + let mut count = 0usize; + render_nodes(nodes, 0, max_nodes, &mut count, &mut buf, &mut refs); + if count >= max_nodes { + buf.push_str(&format!( + "\n[accessibility tree truncated at {max_nodes} nodes — increase max_snapshot_nodes if needed]" + )); + } + (buf, refs) +} + +fn render_nodes( + nodes: &[AXNode], + depth: usize, + max: usize, + count: &mut usize, + buf: &mut String, + refs: &mut HashMap, +) { + let indent = " ".repeat(depth); + for node in nodes { + if *count >= max { + return; + } + let ref_id = format!("e{}", node.backend_node_id); + refs.insert(ref_id.clone(), node.backend_node_id); + buf.push_str(&format!( + "{}- {} \"{}\" [ref={}]\n", + indent, node.role, node.name, ref_id + )); + *count += 1; + if !node.children.is_empty() { + render_nodes(&node.children, depth + 1, max, count, buf, refs); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ax_tree_to_markdown_with_refs() { + let nodes = vec![ + AXNode { + backend_node_id: 1, + role: "button".into(), + name: "Login".into(), + children: vec![], + }, + AXNode { + backend_node_id: 2, + role: "textbox".into(), + name: "Email".into(), + children: vec![], + }, + ]; + let (markdown, refs) = render_ax_tree(&nodes, 5000); + assert!( + markdown.contains("button \"Login\" [ref=e1]"), + "got: {markdown}" + ); + assert!( + markdown.contains("textbox \"Email\" [ref=e2]"), + "got: {markdown}" + ); + assert_eq!(refs.get("e1"), Some(&1u64)); + assert_eq!(refs.get("e2"), Some(&2u64)); + } + + #[test] + fn ax_tree_truncates_at_max_nodes() { + let nodes: Vec = (1..=10) + .map(|i| AXNode { + backend_node_id: i, + role: "button".into(), + name: format!("btn{i}"), + children: vec![], + }) + .collect(); + let (markdown, refs) = render_ax_tree(&nodes, 5); + assert!( + markdown.contains("truncated"), + "expected truncation notice, got: {markdown}" + ); + assert_eq!(refs.len(), 5, "expected 5 refs, got {}", refs.len()); + } + + #[test] + fn nested_children_rendered_with_indent() { + let nodes = vec![AXNode { + backend_node_id: 1, + role: "list".into(), + name: "nav".into(), + children: vec![AXNode { + backend_node_id: 2, + role: "listitem".into(), + name: "Home".into(), + children: vec![], + }], + }]; + let (markdown, _) = render_ax_tree(&nodes, 5000); + // Parent at depth 0, child at depth 1 (2-space indent) + assert!( + markdown.contains(" - listitem"), + "expected indented child, got: {markdown}" + ); + } + + #[test] + fn empty_tree_returns_empty_string_and_empty_refs() { + let (markdown, refs) = render_ax_tree(&[], 5000); + assert!( + markdown.is_empty() || !markdown.contains("[ref="), + "got: {markdown}" + ); + assert!(refs.is_empty()); + } +} diff --git a/crates/earl-protocol-browser/src/builder.rs b/crates/earl-protocol-browser/src/builder.rs new file mode 100644 index 0000000..f3fa0ed --- /dev/null +++ b/crates/earl-protocol-browser/src/builder.rs @@ -0,0 +1,219 @@ +use anyhow::{Result, bail}; +use earl_core::render::TemplateRenderer; +use serde_json::Value; + +use crate::PreparedBrowserCommand; +use crate::schema::{BrowserOperationTemplate, BrowserStep}; + +/// Build a `PreparedBrowserCommand` from a `BrowserOperationTemplate` by +/// rendering all Jinja template strings with the given context. +pub fn build_browser_request( + op: &BrowserOperationTemplate, + context: &Value, + renderer: &dyn TemplateRenderer, +) -> Result { + let tmpl = &op.browser; + + if tmpl.steps.is_empty() { + bail!("operation.browser.steps must not be empty"); + } + + let session_id = tmpl + .session_id + .as_deref() + .map(|s| renderer.render_str(s, context)) + .transpose()? + .filter(|s| !s.is_empty()); + + // Render all string fields in each step via the renderer. + let steps: Vec = tmpl + .steps + .iter() + .map(|step| render_step(step, context, renderer)) + .collect::>()?; + + Ok(PreparedBrowserCommand { + session_id, + headless: tmpl.headless, + timeout_ms: tmpl.timeout_ms, + on_failure_screenshot: tmpl.on_failure_screenshot, + steps, + }) +} + +/// Render all Jinja template strings within a step by round-tripping through +/// `serde_json::Value` and walking every string node through the renderer. +fn render_step(step: &BrowserStep, ctx: &Value, r: &dyn TemplateRenderer) -> Result { + let mut val = serde_json::to_value(step)?; + render_strings_in_value(&mut val, ctx, r)?; + Ok(serde_json::from_value(val)?) +} + +fn render_strings_in_value(v: &mut Value, ctx: &Value, r: &dyn TemplateRenderer) -> Result<()> { + match v { + Value::String(s) => *s = r.render_str(s, ctx)?, + Value::Object(map) => { + for (k, val) in map.iter_mut() { + if k == "action" { + // Never render the serde tag discriminant - it must stay unchanged + continue; + } + render_strings_in_value(val, ctx, r)?; + } + } + Value::Array(arr) => { + for val in arr.iter_mut() { + render_strings_in_value(val, ctx, r)?; + } + } + _ => {} + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct PassthroughRenderer; + + impl TemplateRenderer for PassthroughRenderer { + fn render_str(&self, t: &str, _ctx: &Value) -> anyhow::Result { + Ok(t.to_string()) + } + + fn render_value(&self, v: &Value, _ctx: &Value) -> anyhow::Result { + Ok(v.clone()) + } + } + + #[test] + fn build_renders_session_id_and_url() { + let op: crate::schema::BrowserOperationTemplate = serde_json::from_str( + r#"{ + "browser": { + "session_id": "my-session", + "steps": [{"action":"navigate","url":"https://example.com"}] + } + }"#, + ) + .unwrap(); + let ctx = serde_json::json!({"args": {}, "secrets": {}}); + let cmd = build_browser_request(&op, &ctx, &PassthroughRenderer).unwrap(); + assert_eq!(cmd.session_id.as_deref(), Some("my-session")); + assert_eq!(cmd.steps.len(), 1); + } + + #[test] + fn empty_steps_returns_error() { + let op: crate::schema::BrowserOperationTemplate = + serde_json::from_str(r#"{"browser": {"steps": []}}"#).unwrap(); + let ctx = serde_json::json!({}); + assert!(build_browser_request(&op, &ctx, &PassthroughRenderer).is_err()); + } + + #[test] + fn empty_session_id_becomes_none() { + let op: crate::schema::BrowserOperationTemplate = serde_json::from_str( + r#"{ + "browser": { + "session_id": "", + "steps": [{"action":"snapshot"}] + } + }"#, + ) + .unwrap(); + let ctx = serde_json::json!({}); + let cmd = build_browser_request(&op, &ctx, &PassthroughRenderer).unwrap(); + assert!(cmd.session_id.is_none()); + } + + #[test] + fn build_preserves_headless_and_timeout() { + let op: crate::schema::BrowserOperationTemplate = serde_json::from_str( + r#"{ + "browser": { + "headless": false, + "timeout_ms": 60000, + "on_failure_screenshot": false, + "steps": [{"action":"snapshot"}] + } + }"#, + ) + .unwrap(); + let ctx = serde_json::json!({}); + let cmd = build_browser_request(&op, &ctx, &PassthroughRenderer).unwrap(); + assert!(!cmd.headless); + assert_eq!(cmd.timeout_ms, 60000); + assert!(!cmd.on_failure_screenshot); + } + + #[test] + fn render_step_strings_are_passed_through_renderer() { + /// Renderer that only transforms non-action strings by uppercasing them. + /// We use a targeted substitution so we can distinguish rendered from + /// original values without corrupting the serde `action` discriminant. + struct UppercaseUrlRenderer; + impl TemplateRenderer for UppercaseUrlRenderer { + fn render_str(&self, t: &str, _ctx: &Value) -> anyhow::Result { + // Only transform strings that look like URLs (contain "://"). + if t.contains("://") { + Ok(t.to_uppercase()) + } else { + Ok(t.to_string()) + } + } + fn render_value(&self, v: &Value, _ctx: &Value) -> anyhow::Result { + Ok(v.clone()) + } + } + + let op: crate::schema::BrowserOperationTemplate = serde_json::from_str( + r#"{ + "browser": { + "steps": [{"action":"navigate","url":"https://example.com"}] + } + }"#, + ) + .unwrap(); + let ctx = serde_json::json!({}); + let cmd = build_browser_request(&op, &ctx, &UppercaseUrlRenderer).unwrap(); + // The url field is a string containing "://" so it gets uppercased. + if let crate::schema::BrowserStep::Navigate { url, .. } = &cmd.steps[0] { + assert_eq!(url, "HTTPS://EXAMPLE.COM"); + } else { + panic!("expected Navigate step"); + } + } + + #[test] + fn render_does_not_corrupt_action_discriminant() { + struct UppercaseRenderer; + impl earl_core::TemplateRenderer for UppercaseRenderer { + fn render_str(&self, t: &str, _ctx: &Value) -> anyhow::Result { + Ok(t.to_uppercase()) + } + fn render_value(&self, v: &Value, _ctx: &Value) -> anyhow::Result { + Ok(v.clone()) + } + } + let op: crate::schema::BrowserOperationTemplate = serde_json::from_str( + r#"{ + "browser": { + "steps": [{"action":"navigate","url":"https://example.com"}] + } + }"#, + ) + .unwrap(); + let ctx = serde_json::json!({}); + // This should NOT fail — action discriminant must be preserved + let cmd = build_browser_request(&op, &ctx, &UppercaseRenderer).unwrap(); + assert_eq!(cmd.steps.len(), 1); + // URL should be uppercased + if let crate::schema::BrowserStep::Navigate { url, .. } = &cmd.steps[0] { + assert_eq!(url, "HTTPS://EXAMPLE.COM"); + } else { + panic!("expected Navigate step"); + } + } +} diff --git a/crates/earl-protocol-browser/src/error.rs b/crates/earl-protocol-browser/src/error.rs new file mode 100644 index 0000000..fe7c735 --- /dev/null +++ b/crates/earl-protocol-browser/src/error.rs @@ -0,0 +1,111 @@ +#[derive(Debug, thiserror::Error)] +pub enum BrowserError { + #[error( + "browser step {step} ({action}) failed: element not found — {selector} — completed {completed} of {total} steps" + )] + ElementNotFound { + step: usize, + action: String, + selector: String, + completed: usize, + total: usize, + }, + + #[error( + "browser step {step} ({action}) failed: element not interactable — {selector} — completed {completed} of {total} steps" + )] + ElementNotInteractable { + step: usize, + action: String, + selector: String, + completed: usize, + total: usize, + }, + + #[error("browser step {step} (navigate) failed: {reason}")] + NavigationFailed { step: usize, reason: String }, + + #[error("browser step {step} ({action}) assertion failed: {message}")] + AssertionFailed { + step: usize, + action: String, + message: String, + }, + + #[error("browser renderer crashed at step {step}")] + RendererCrashed { step: usize }, + + #[error( + "browser step {step}: a dialog is blocking the page — add a handle_dialog step before this one" + )] + DialogBlocking { step: usize }, + + #[error( + "browser step {step}: a download was triggered — use a download step with save_to to handle it explicitly" + )] + DownloadBlocked { step: usize }, + + #[error( + "browser step {step} (click): a new tab was opened — add a tabs step with operation=\"select\" to switch to it" + )] + NewTabOpened { step: usize }, + + #[error("browser step {step} ({action}) timed out after {timeout_ms}ms")] + Timeout { + step: usize, + action: String, + timeout_ms: u64, + }, + + #[error("ref \"{ref_id}\" no longer exists in the accessibility tree (used in {action})")] + StaleRef { ref_id: String, action: String }, + + #[error( + "URL scheme \"{scheme}\" is not allowed in navigate — only http and https are permitted" + )] + DisallowedScheme { scheme: String }, + + // session_id intentionally omitted from the Display string to avoid CWE-532 (cleartext + // logging of sensitive identifiers). The caller already knows which session they requested. + #[error("browser session is locked by another earl process (PID {pid})")] + SessionLocked { session_id: String, pid: u32 }, + + #[error( + "Chrome not found — install Chrome/Chromium or set EARL_BROWSER_PATH\nPaths tried:\n{paths}" + )] + ChromeNotFound { paths: String }, + + #[error("browser session lost during step {step} ({action}): {reason}")] + SessionLost { + step: usize, + action: String, + reason: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_messages_include_context() { + let e = BrowserError::ElementNotFound { + step: 2, + action: "click".into(), + selector: "#submit".into(), + completed: 1, + total: 5, + }; + let msg = e.to_string(); + assert!(msg.contains("step 2")); + assert!(msg.contains("click")); + assert!(msg.contains("#submit")); + assert!(msg.contains("completed 1 of 5")); + + let e2 = BrowserError::DisallowedScheme { + scheme: "file".into(), + }; + assert!(e2.to_string().contains("file")); + assert!(e2.to_string().contains("http")); + } +} diff --git a/crates/earl-protocol-browser/src/executor.rs b/crates/earl-protocol-browser/src/executor.rs new file mode 100644 index 0000000..2a3894b --- /dev/null +++ b/crates/earl-protocol-browser/src/executor.rs @@ -0,0 +1,185 @@ +use anyhow::Result; +use chrono::Utc; +use earl_core::{ExecutionContext, ProtocolExecutor, RawExecutionResult}; + +use crate::PreparedBrowserCommand; +use crate::launcher::{configure_page, connect_chrome, launch_chrome}; +use crate::session::{ + SessionFile, acquire_session_lock, ensure_sessions_dir, is_pid_alive, session_file_path, + sessions_dir, +}; +use crate::steps::execute_steps; + +/// Browser protocol executor. +/// +/// Supports two modes: +/// - **Ephemeral** (`session_id` is `None`): launches Chrome, opens a fresh +/// page, runs the steps, then closes Chrome. +/// - **Session** (`session_id` is `Some`): acquires an advisory lock on the +/// session file, reconnects to an existing Chrome instance if it is still +/// alive, otherwise launches a fresh one; runs the steps; updates the session +/// file. +pub struct BrowserExecutor; + +impl ProtocolExecutor for BrowserExecutor { + type PreparedData = PreparedBrowserCommand; + + async fn execute( + &mut self, + data: &PreparedBrowserCommand, + _ctx: &ExecutionContext, + ) -> Result { + let result = run_browser_command(data).await?; + let body = serde_json::to_vec(&result)?; + Ok(RawExecutionResult { + status: 0, + url: "browser://command".into(), + body, + content_type: Some("application/json".into()), + }) + } +} + +/// Core execution logic — runs the browser steps and returns a JSON `Value`. +async fn run_browser_command(data: &PreparedBrowserCommand) -> Result { + match data.session_id.as_deref() { + None => run_ephemeral(data).await, + Some(session_id) => run_with_session(data, session_id).await, + } +} + +/// Launch a fresh Chrome instance, run the steps on a new page, then close. +async fn run_ephemeral(data: &PreparedBrowserCommand) -> Result { + let (mut browser, _ws_url) = launch_chrome(data.headless).await?; + + let page = match browser.new_page("about:blank").await { + Ok(p) => p, + Err(e) => { + let _ = browser.close().await; + return Err(e.into()); + } + }; + if let Err(e) = configure_page(&page).await { + let _ = browser.close().await; + return Err(e); + } + + let result = execute_steps( + &page, + &data.steps, + data.timeout_ms, + data.on_failure_screenshot, + ) + .await; + + // Close Chrome regardless of step outcome. + let _ = browser.close().await; + + result +} + +/// Connect to (or launch) a Chrome instance tracked by a session file, run the +/// steps, then update the session file with the current state. +async fn run_with_session( + data: &PreparedBrowserCommand, + session_id: &str, +) -> Result { + // Ensure the sessions directory exists before acquiring the lock. + let dir = sessions_dir()?; + ensure_sessions_dir(&dir)?; + + // Advisory lock prevents concurrent earl invocations from clobbering the + // same session. + let _lock = acquire_session_lock(session_id).await?; + + let sf_path = session_file_path(session_id)?; + let existing = SessionFile::load_from(&sf_path)?; + + // Try to reconnect to an existing Chrome instance. + let (browser, ws_url) = if let Some(ref sf) = existing { + if is_pid_alive(sf.pid, Some(sf.started_at)) { + match connect_chrome(&sf.websocket_url).await { + Ok(b) => (b, sf.websocket_url.clone()), + Err(_) => { + // Stale session — launch a fresh Chrome. + let (b, ws) = launch_chrome(data.headless).await?; + (b, ws) + } + } + } else { + // PID no longer alive — launch a fresh Chrome. + let (b, ws) = launch_chrome(data.headless).await?; + (b, ws) + } + } else { + // No session file yet — launch a fresh Chrome. + let (b, ws) = launch_chrome(data.headless).await?; + (b, ws) + }; + + // Reuse the first existing page or open a new one. + // configure_page must be called in both branches so that security settings + // (e.g. download blocking) are always applied, even to reused pages. + let page = match browser.pages().await { + Ok(pages) if !pages.is_empty() => { + let p = pages.into_iter().next().unwrap(); + configure_page(&p).await?; + p + } + _ => { + let p = browser.new_page("about:blank").await?; + configure_page(&p).await?; + p + } + }; + + // Prepare the session file (will be saved after steps run). + let target_id = page.target_id().as_ref().to_string(); + + let now = Utc::now(); + let started_at = existing.as_ref().map(|sf| sf.started_at).unwrap_or(now); + + let sf_to_save = SessionFile { + // Use 0 as a placeholder — chromiumoxide does not expose Chrome's PID + // through its public API. + pid: 0, + websocket_url: ws_url, + target_id, + started_at, + last_used_at: now, + interrupted: false, + }; + + // Run the steps. + let step_result = execute_steps( + &page, + &data.steps, + data.timeout_ms, + data.on_failure_screenshot, + ) + .await; + + // Save the session file after steps complete, recording whether they failed. + let mut updated_sf = sf_to_save; + updated_sf.last_used_at = Utc::now(); + updated_sf.interrupted = step_result.is_err(); + // best-effort — don't mask the step error, but warn so "session didn't persist" is debuggable + // Omit session_id and session file path from the log: the path encodes the session identifier + // which CodeQL treats as sensitive (CWE-532). The IO error itself provides enough context. + if let Err(e) = updated_sf.save_to(&sf_path) { + tracing::warn!(error = %e, "failed to persist browser session file"); + } + + step_result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn browser_executor_implements_protocol_executor() { + fn assert_impl() {} + assert_impl::(); + } +} diff --git a/crates/earl-protocol-browser/src/launcher.rs b/crates/earl-protocol-browser/src/launcher.rs new file mode 100644 index 0000000..3f18f7a --- /dev/null +++ b/crates/earl-protocol-browser/src/launcher.rs @@ -0,0 +1,172 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use chromiumoxide::browser::BrowserConfig; +use chromiumoxide::handler::Handler; +use chromiumoxide::{Browser, Page}; +use futures::StreamExt; + +use crate::error::BrowserError; + +/// Platform-ordered list of Chrome binary candidates. +/// +/// If the `EARL_BROWSER_PATH` environment variable is set, it is returned as +/// the sole candidate (no fallbacks are tried). Otherwise, a list of +/// well-known installation paths for the current platform is returned, +/// followed by any matches found on `PATH` via the `which` crate. +pub fn chrome_binary_candidates() -> Vec { + // EARL_BROWSER_PATH override takes priority and is the only result. + if let Ok(p) = std::env::var("EARL_BROWSER_PATH") { + return vec![PathBuf::from(p)]; + } + + let mut candidates = vec![]; + + #[cfg(target_os = "macos")] + { + candidates.push(PathBuf::from( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + )); + candidates.push(PathBuf::from( + "/Applications/Chromium.app/Contents/MacOS/Chromium", + )); + } + + #[cfg(target_os = "linux")] + { + candidates.push(PathBuf::from("/usr/bin/google-chrome")); + candidates.push(PathBuf::from("/usr/bin/google-chrome-stable")); + candidates.push(PathBuf::from("/usr/bin/chromium-browser")); + candidates.push(PathBuf::from("/usr/bin/chromium")); + } + + #[cfg(target_os = "windows")] + { + candidates.push(PathBuf::from( + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + )); + candidates.push(PathBuf::from( + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + )); + } + + // PATH fallbacks via `which` crate. + for name in &["chrome", "google-chrome", "chromium", "chromium-browser"] { + if let Ok(p) = which::which(name) + && !candidates.contains(&p) + { + candidates.push(p); + } + } + + candidates +} + +/// Find the Chrome binary, returning the first path that exists. +/// +/// Returns `BrowserError::ChromeNotFound` if none of the candidates exist. +pub fn find_chrome() -> Result { + let candidates = chrome_binary_candidates(); + for path in &candidates { + if path.exists() { + return Ok(path.clone()); + } + } + let paths = candidates + .iter() + .map(|p| format!(" - {}", p.display())) + .collect::>() + .join("\n"); + Err(BrowserError::ChromeNotFound { paths }.into()) +} + +/// Spawn the chromiumoxide `Handler` on a Tokio task. +/// +/// The handler **must** be polled continuously — if it stops, all CDP commands +/// will deadlock. This helper spawns it as a background task that runs until +/// the handler's stream is exhausted (i.e., the browser connection closes). +fn spawn_handler(mut handler: Handler) { + tokio::spawn(async move { while handler.next().await.is_some() {} }); +} + +/// Launch a new Chrome/Chromium instance and return the connected `Browser`. +/// +/// The handler task is spawned automatically; callers do not need to manage it. +/// The second element of the returned tuple is the WebSocket debug URL for the +/// launched instance — useful for reconnecting or recording the session. +/// +/// # Arguments +/// * `headless` — `true` runs in headless mode (default Chrome headless); `false` shows the window. +pub async fn launch_chrome(headless: bool) -> Result<(Browser, String)> { + let chrome = find_chrome()?; + + let mut config_builder = BrowserConfig::builder() + .chrome_executable(chrome) + // Disable chromiumoxide's own request timeout; Earl manages timeouts externally. + .request_timeout(std::time::Duration::from_secs(3600)); + + if !headless { + config_builder = config_builder.with_head(); + } + + let config = config_builder + .build() + .map_err(|e| anyhow::anyhow!("browser config error: {e}"))?; + + let (browser, handler) = Browser::launch(config).await.context("launching Chrome")?; + + spawn_handler(handler); + + let ws_url = browser.websocket_address().clone(); + Ok((browser, ws_url)) +} + +/// Connect to an existing Chrome instance by WebSocket (or HTTP debug) URL. +/// +/// The handler task is spawned automatically. +pub async fn connect_chrome(ws_url: &str) -> Result { + let (browser, handler) = Browser::connect(ws_url) + .await + .context("connecting to Chrome CDP")?; + + spawn_handler(handler); + + Ok(browser) +} + +/// Apply Earl's default page configuration after a page is created. +/// +/// Currently this denies all downloads so that unexpected file saves surface +/// as an error rather than silently writing to disk. +pub async fn configure_page(page: &Page) -> Result<()> { + use chromiumoxide::cdp::browser_protocol::browser::{ + SetDownloadBehaviorBehavior, SetDownloadBehaviorParams, + }; + + // Deny all downloads by default. + let params = SetDownloadBehaviorParams::new(SetDownloadBehaviorBehavior::Deny); + + page.execute(params) + .await + .context("setting download behavior to deny")?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chrome_binary_candidates_non_empty() { + let candidates = chrome_binary_candidates(); + assert!(!candidates.is_empty()); + } + + #[test] + fn find_chrome_returns_result() { + // Should either find Chrome or return a ChromeNotFound error. + // Either outcome is valid — we just check it doesn't panic. + let _ = find_chrome(); + } +} diff --git a/crates/earl-protocol-browser/src/lib.rs b/crates/earl-protocol-browser/src/lib.rs new file mode 100644 index 0000000..56d6aec --- /dev/null +++ b/crates/earl-protocol-browser/src/lib.rs @@ -0,0 +1,24 @@ +pub mod accessibility; +pub mod builder; +pub mod error; +pub mod executor; +pub mod launcher; +pub mod schema; +pub mod session; +pub mod steps; + +pub use error::BrowserError; +pub use executor::BrowserExecutor; +pub use schema::BrowserOperationTemplate; + +use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize}; + +/// Prepared browser command data, ready for execution. +#[derive(Debug, Clone, Archive, RkyvSerialize, RkyvDeserialize)] +pub struct PreparedBrowserCommand { + pub session_id: Option, + pub headless: bool, + pub timeout_ms: u64, + pub on_failure_screenshot: bool, + pub steps: Vec, +} diff --git a/crates/earl-protocol-browser/src/schema.rs b/crates/earl-protocol-browser/src/schema.rs new file mode 100644 index 0000000..2d931e3 --- /dev/null +++ b/crates/earl-protocol-browser/src/schema.rs @@ -0,0 +1,766 @@ +use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize, Serialize, Archive, RkyvSerialize, RkyvDeserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserOperationTemplate { + pub browser: BrowserTemplate, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Archive, RkyvSerialize, RkyvDeserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserTemplate { + pub session_id: Option, + #[serde(default = "default_headless")] + pub headless: bool, + #[serde(default = "default_timeout_ms")] + pub timeout_ms: u64, + #[serde(default = "default_true")] + pub on_failure_screenshot: bool, + pub steps: Vec, +} + +fn default_headless() -> bool { + true +} +fn default_timeout_ms() -> u64 { + 30_000 +} +fn default_true() -> bool { + true +} + +// ── BrowserStep ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Deserialize, Serialize, Archive, RkyvSerialize, RkyvDeserialize)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +pub enum BrowserStep { + // ── Navigation ────────────────────────────────────────────────────────── + Navigate { + url: String, + #[serde(default)] + expected_status: Option, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + NavigateBack { + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + NavigateForward { + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + Reload { + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + + // ── Observation ───────────────────────────────────────────────────────── + Snapshot { + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + Screenshot { + #[serde(default)] + path: Option, + #[serde(default, rename = "type")] + r#type: Option, + #[serde(default)] + full_page: bool, + #[serde(default, rename = "ref")] + r#ref: Option, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + ConsoleMessages { + #[serde(default)] + level: Option, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + ConsoleClear { + #[serde(default)] + optional: bool, + }, + NetworkRequests { + #[serde(default)] + include_static: bool, + #[serde(default)] + optional: bool, + }, + NetworkClear { + #[serde(default)] + optional: bool, + }, + + // ── Interaction ───────────────────────────────────────────────────────── + Click { + #[serde(default, rename = "ref")] + r#ref: Option, + #[serde(default)] + selector: Option, + #[serde(default)] + double_click: bool, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + Hover { + #[serde(default, rename = "ref")] + r#ref: Option, + #[serde(default)] + selector: Option, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + Drag { + #[serde(default, rename = "start_ref")] + start_ref: Option, + #[serde(default)] + start_selector: Option, + #[serde(default, rename = "end_ref")] + end_ref: Option, + #[serde(default)] + end_selector: Option, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + Fill { + #[serde(default, rename = "ref")] + r#ref: Option, + #[serde(default)] + selector: Option, + text: String, + #[serde(default)] + submit: Option, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + FillForm { + #[rkyv(with = earl_core::with::AsJson)] + fields: Vec, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + SelectOption { + #[serde(default, rename = "ref")] + r#ref: Option, + #[serde(default)] + selector: Option, + values: Vec, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + PressKey { + key: String, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + Check { + #[serde(default, rename = "ref")] + r#ref: Option, + #[serde(default)] + selector: Option, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + Uncheck { + #[serde(default, rename = "ref")] + r#ref: Option, + #[serde(default)] + selector: Option, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + + // ── Mouse ──────────────────────────────────────────────────────────────── + MouseMove { + x: f64, + y: f64, + #[serde(default)] + optional: bool, + }, + MouseClick { + x: f64, + y: f64, + #[serde(default)] + button: Option, + #[serde(default)] + optional: bool, + }, + MouseDrag { + start_x: f64, + start_y: f64, + end_x: f64, + end_y: f64, + #[serde(default)] + optional: bool, + }, + MouseDown { + #[serde(default)] + button: Option, + #[serde(default)] + optional: bool, + }, + MouseUp { + #[serde(default)] + button: Option, + #[serde(default)] + optional: bool, + }, + MouseWheel { + delta_x: f64, + delta_y: f64, + #[serde(default)] + optional: bool, + }, + + // ── Wait & Assert ──────────────────────────────────────────────────────── + WaitFor { + #[serde(default)] + time: Option, + #[serde(default)] + text: Option, + #[serde(default)] + text_gone: Option, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + VerifyElementVisible { + #[serde(default)] + role: Option, + #[serde(default)] + accessible_name: Option, + #[serde(default)] + optional: bool, + }, + VerifyTextVisible { + text: String, + #[serde(default)] + optional: bool, + }, + VerifyListVisible { + #[serde(default, rename = "ref")] + r#ref: Option, + items: Vec, + #[serde(default)] + optional: bool, + }, + VerifyValue { + #[serde(default, rename = "ref")] + r#ref: Option, + value: String, + #[serde(default)] + optional: bool, + }, + + // ── JavaScript ─────────────────────────────────────────────────────────── + Evaluate { + function: String, + #[serde(default, rename = "ref")] + r#ref: Option, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + RunCode { + code: String, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + + // ── Tabs & Viewport ────────────────────────────────────────────────────── + Tabs { + operation: String, + #[serde(default)] + index: Option, + #[serde(default)] + optional: bool, + }, + Resize { + width: u32, + height: u32, + #[serde(default)] + optional: bool, + }, + Close { + #[serde(default)] + optional: bool, + }, + + // ── Network mocking ────────────────────────────────────────────────────── + Route { + pattern: String, + #[serde(default)] + status: Option, + #[serde(default)] + body: Option, + #[serde(default)] + content_type: Option, + #[serde(default)] + optional: bool, + }, + RouteList { + #[serde(default)] + optional: bool, + }, + Unroute { + pattern: String, + #[serde(default)] + optional: bool, + }, + + // ── Cookies ────────────────────────────────────────────────────────────── + CookieList { + #[serde(default)] + domain: Option, + #[serde(default)] + optional: bool, + }, + CookieGet { + name: String, + #[serde(default)] + optional: bool, + }, + CookieSet { + name: String, + value: String, + #[serde(default)] + domain: Option, + #[serde(default)] + path: Option, + #[serde(default)] + expires: Option, + #[serde(default)] + http_only: bool, + #[serde(default)] + secure: bool, + #[serde(default)] + optional: bool, + }, + CookieDelete { + name: String, + #[serde(default)] + optional: bool, + }, + CookieClear { + #[serde(default)] + optional: bool, + }, + + // ── Web Storage ────────────────────────────────────────────────────────── + LocalStorageGet { + key: String, + #[serde(default)] + optional: bool, + }, + LocalStorageSet { + key: String, + value: String, + #[serde(default)] + optional: bool, + }, + LocalStorageDelete { + key: String, + #[serde(default)] + optional: bool, + }, + LocalStorageClear { + #[serde(default)] + optional: bool, + }, + SessionStorageGet { + key: String, + #[serde(default)] + optional: bool, + }, + SessionStorageSet { + key: String, + value: String, + #[serde(default)] + optional: bool, + }, + SessionStorageDelete { + key: String, + #[serde(default)] + optional: bool, + }, + SessionStorageClear { + #[serde(default)] + optional: bool, + }, + StorageState { + #[serde(default)] + path: Option, + #[serde(default)] + optional: bool, + }, + SetStorageState { + path: String, + #[serde(default)] + optional: bool, + }, + + // ── File, Dialog, Download ─────────────────────────────────────────────── + FileUpload { + paths: Vec, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + HandleDialog { + accept: bool, + #[serde(default)] + prompt_text: Option, + #[serde(default)] + optional: bool, + }, + Download { + save_to: String, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + optional: bool, + }, + + // ── Output ─────────────────────────────────────────────────────────────── + PdfSave { + #[serde(default)] + path: Option, + #[serde(default)] + optional: bool, + }, + StartVideo { + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, + #[serde(default)] + optional: bool, + }, + StopVideo { + #[serde(default)] + path: Option, + #[serde(default)] + optional: bool, + }, + StartTracing { + #[serde(default)] + optional: bool, + }, + StopTracing { + #[serde(default)] + path: Option, + #[serde(default)] + optional: bool, + }, + GenerateLocator { + #[serde(rename = "ref")] + r#ref: String, + #[serde(default)] + optional: bool, + }, +} + +impl BrowserStep { + pub fn action_name(&self) -> &'static str { + match self { + Self::Navigate { .. } => "navigate", + Self::NavigateBack { .. } => "navigate_back", + Self::NavigateForward { .. } => "navigate_forward", + Self::Reload { .. } => "reload", + Self::Snapshot { .. } => "snapshot", + Self::Screenshot { .. } => "screenshot", + Self::ConsoleMessages { .. } => "console_messages", + Self::ConsoleClear { .. } => "console_clear", + Self::NetworkRequests { .. } => "network_requests", + Self::NetworkClear { .. } => "network_clear", + Self::Click { .. } => "click", + Self::Hover { .. } => "hover", + Self::Drag { .. } => "drag", + Self::Fill { .. } => "fill", + Self::FillForm { .. } => "fill_form", + Self::SelectOption { .. } => "select_option", + Self::PressKey { .. } => "press_key", + Self::Check { .. } => "check", + Self::Uncheck { .. } => "uncheck", + Self::MouseMove { .. } => "mouse_move", + Self::MouseClick { .. } => "mouse_click", + Self::MouseDrag { .. } => "mouse_drag", + Self::MouseDown { .. } => "mouse_down", + Self::MouseUp { .. } => "mouse_up", + Self::MouseWheel { .. } => "mouse_wheel", + Self::WaitFor { .. } => "wait_for", + Self::VerifyElementVisible { .. } => "verify_element_visible", + Self::VerifyTextVisible { .. } => "verify_text_visible", + Self::VerifyListVisible { .. } => "verify_list_visible", + Self::VerifyValue { .. } => "verify_value", + Self::Evaluate { .. } => "evaluate", + Self::RunCode { .. } => "run_code", + Self::Tabs { .. } => "tabs", + Self::Resize { .. } => "resize", + Self::Close { .. } => "close", + Self::Route { .. } => "route", + Self::RouteList { .. } => "route_list", + Self::Unroute { .. } => "unroute", + Self::CookieList { .. } => "cookie_list", + Self::CookieGet { .. } => "cookie_get", + Self::CookieSet { .. } => "cookie_set", + Self::CookieDelete { .. } => "cookie_delete", + Self::CookieClear { .. } => "cookie_clear", + Self::LocalStorageGet { .. } => "local_storage_get", + Self::LocalStorageSet { .. } => "local_storage_set", + Self::LocalStorageDelete { .. } => "local_storage_delete", + Self::LocalStorageClear { .. } => "local_storage_clear", + Self::SessionStorageGet { .. } => "session_storage_get", + Self::SessionStorageSet { .. } => "session_storage_set", + Self::SessionStorageDelete { .. } => "session_storage_delete", + Self::SessionStorageClear { .. } => "session_storage_clear", + Self::StorageState { .. } => "storage_state", + Self::SetStorageState { .. } => "set_storage_state", + Self::FileUpload { .. } => "file_upload", + Self::HandleDialog { .. } => "handle_dialog", + Self::Download { .. } => "download", + Self::PdfSave { .. } => "pdf_save", + Self::StartVideo { .. } => "start_video", + Self::StopVideo { .. } => "stop_video", + Self::StartTracing { .. } => "start_tracing", + Self::StopTracing { .. } => "stop_tracing", + Self::GenerateLocator { .. } => "generate_locator", + } + } + + pub fn is_optional(&self) -> bool { + match self { + Self::Navigate { optional, .. } => *optional, + Self::NavigateBack { optional, .. } => *optional, + Self::NavigateForward { optional, .. } => *optional, + Self::Reload { optional, .. } => *optional, + Self::Snapshot { optional, .. } => *optional, + Self::Screenshot { optional, .. } => *optional, + Self::ConsoleMessages { optional, .. } => *optional, + Self::ConsoleClear { optional, .. } => *optional, + Self::NetworkRequests { optional, .. } => *optional, + Self::NetworkClear { optional, .. } => *optional, + Self::Click { optional, .. } => *optional, + Self::Hover { optional, .. } => *optional, + Self::Drag { optional, .. } => *optional, + Self::Fill { optional, .. } => *optional, + Self::FillForm { optional, .. } => *optional, + Self::SelectOption { optional, .. } => *optional, + Self::PressKey { optional, .. } => *optional, + Self::Check { optional, .. } => *optional, + Self::Uncheck { optional, .. } => *optional, + Self::MouseMove { optional, .. } => *optional, + Self::MouseClick { optional, .. } => *optional, + Self::MouseDrag { optional, .. } => *optional, + Self::MouseDown { optional, .. } => *optional, + Self::MouseUp { optional, .. } => *optional, + Self::MouseWheel { optional, .. } => *optional, + Self::WaitFor { optional, .. } => *optional, + Self::VerifyElementVisible { optional, .. } => *optional, + Self::VerifyTextVisible { optional, .. } => *optional, + Self::VerifyListVisible { optional, .. } => *optional, + Self::VerifyValue { optional, .. } => *optional, + Self::Evaluate { optional, .. } => *optional, + Self::RunCode { optional, .. } => *optional, + Self::Tabs { optional, .. } => *optional, + Self::Resize { optional, .. } => *optional, + Self::Close { optional, .. } => *optional, + Self::Route { optional, .. } => *optional, + Self::RouteList { optional, .. } => *optional, + Self::Unroute { optional, .. } => *optional, + Self::CookieList { optional, .. } => *optional, + Self::CookieGet { optional, .. } => *optional, + Self::CookieSet { optional, .. } => *optional, + Self::CookieDelete { optional, .. } => *optional, + Self::CookieClear { optional, .. } => *optional, + Self::LocalStorageGet { optional, .. } => *optional, + Self::LocalStorageSet { optional, .. } => *optional, + Self::LocalStorageDelete { optional, .. } => *optional, + Self::LocalStorageClear { optional, .. } => *optional, + Self::SessionStorageGet { optional, .. } => *optional, + Self::SessionStorageSet { optional, .. } => *optional, + Self::SessionStorageDelete { optional, .. } => *optional, + Self::SessionStorageClear { optional, .. } => *optional, + Self::StorageState { optional, .. } => *optional, + Self::SetStorageState { optional, .. } => *optional, + Self::FileUpload { optional, .. } => *optional, + Self::HandleDialog { optional, .. } => *optional, + Self::Download { optional, .. } => *optional, + Self::PdfSave { optional, .. } => *optional, + Self::StartVideo { optional, .. } => *optional, + Self::StopVideo { optional, .. } => *optional, + Self::StartTracing { optional, .. } => *optional, + Self::StopTracing { optional, .. } => *optional, + Self::GenerateLocator { optional, .. } => *optional, + } + } + + pub fn timeout_ms(&self, global: u64) -> u64 { + match self { + Self::Navigate { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::NavigateBack { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::NavigateForward { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::Reload { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::Snapshot { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::Screenshot { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::ConsoleMessages { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::ConsoleClear { .. } => global, + Self::NetworkRequests { .. } => global, + Self::NetworkClear { .. } => global, + Self::Click { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::Hover { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::Drag { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::Fill { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::FillForm { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::SelectOption { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::PressKey { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::Check { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::Uncheck { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::MouseMove { .. } => global, + Self::MouseClick { .. } => global, + Self::MouseDrag { .. } => global, + Self::MouseDown { .. } => global, + Self::MouseUp { .. } => global, + Self::MouseWheel { .. } => global, + Self::WaitFor { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::VerifyElementVisible { .. } => global, + Self::VerifyTextVisible { .. } => global, + Self::VerifyListVisible { .. } => global, + Self::VerifyValue { .. } => global, + Self::Evaluate { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::RunCode { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::Tabs { .. } => global, + Self::Resize { .. } => global, + Self::Close { .. } => global, + Self::Route { .. } => global, + Self::RouteList { .. } => global, + Self::Unroute { .. } => global, + Self::CookieList { .. } => global, + Self::CookieGet { .. } => global, + Self::CookieSet { .. } => global, + Self::CookieDelete { .. } => global, + Self::CookieClear { .. } => global, + Self::LocalStorageGet { .. } => global, + Self::LocalStorageSet { .. } => global, + Self::LocalStorageDelete { .. } => global, + Self::LocalStorageClear { .. } => global, + Self::SessionStorageGet { .. } => global, + Self::SessionStorageSet { .. } => global, + Self::SessionStorageDelete { .. } => global, + Self::SessionStorageClear { .. } => global, + Self::StorageState { .. } => global, + Self::SetStorageState { .. } => global, + Self::FileUpload { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::HandleDialog { .. } => global, + Self::Download { timeout_ms, .. } => timeout_ms.unwrap_or(global), + Self::PdfSave { .. } => global, + Self::StartVideo { .. } => global, + Self::StopVideo { .. } => global, + Self::StartTracing { .. } => global, + Self::StopTracing { .. } => global, + Self::GenerateLocator { .. } => global, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_navigate_step() { + let json = r#"{"action":"navigate","url":"https://example.com"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!(matches!(step, BrowserStep::Navigate { url, .. } if url == "https://example.com")); + } + + #[test] + fn deserialize_click_step_with_selector() { + let json = "{\"action\":\"click\",\"selector\":\"#submit\"}"; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!(matches!(step, BrowserStep::Click { selector: Some(s), .. } if s == "#submit")); + } + + #[test] + fn deserialize_fill_step() { + let json = r#"{"action":"fill","selector":"input[name=q]","text":"hello","submit":true}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!( + matches!(step, BrowserStep::Fill { text, submit: Some(true), .. } if text == "hello") + ); + } + + #[test] + fn deserialize_operation_template() { + let json = r#"{ + "headless": true, + "steps": [ + {"action":"navigate","url":"https://example.com"}, + {"action":"snapshot"} + ] + }"#; + let tmpl: BrowserTemplate = serde_json::from_str(json).unwrap(); + assert_eq!(tmpl.steps.len(), 2); + } + + #[test] + fn unknown_field_rejected() { + let json = r#"{"action":"navigate","url":"https://x.com","bogus":true}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn optional_defaults_to_false() { + let json = r#"{"action":"navigate","url":"https://x.com"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!(!step.is_optional()); + } + + #[test] + fn timeout_ms_falls_back_to_global() { + let json = r#"{"action":"navigate","url":"https://x.com"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert_eq!(step.timeout_ms(5000), 5000); + } +} diff --git a/crates/earl-protocol-browser/src/session.rs b/crates/earl-protocol-browser/src/session.rs new file mode 100644 index 0000000..4708de5 --- /dev/null +++ b/crates/earl-protocol-browser/src/session.rs @@ -0,0 +1,257 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::error::BrowserError; + +/// Reject session IDs that could be used for path traversal or other abuse. +/// +/// Allowed characters: ASCII letters, digits, hyphens, and underscores. +pub fn validate_session_id(session_id: &str) -> Result<()> { + if session_id.is_empty() { + return Err(anyhow::anyhow!("session_id must not be empty")); + } + if !session_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(anyhow::anyhow!( + "session_id contains invalid characters; only ASCII letters, digits, hyphens, \ + and underscores are allowed" + )); + } + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionFile { + pub pid: u32, + pub websocket_url: String, + pub target_id: String, + pub started_at: DateTime, + pub last_used_at: DateTime, + pub interrupted: bool, +} + +impl SessionFile { + pub fn load_from(path: &Path) -> Result> { + match std::fs::read_to_string(path) { + Ok(contents) => match serde_json::from_str(&contents) { + Ok(f) => Ok(Some(f)), + Err(_) => Ok(None), // corrupt — treat as stale + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e).context("reading session file"), + } + } + + pub fn save_to(&self, path: &Path) -> Result<()> { + let dir = path.parent().unwrap_or(Path::new(".")); + let tmp = tempfile::NamedTempFile::new_in(dir).context("creating temp session file")?; + serde_json::to_writer(&tmp, self).context("serializing session file")?; + tmp.persist(path) + .map_err(|e| anyhow::anyhow!("persisting session file: {}", e.error))?; + Ok(()) + } + + pub fn delete(path: &Path) -> Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e).context("deleting session file"), + } + } +} + +pub fn ensure_sessions_dir(dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir).context("creating sessions directory")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o700); + std::fs::set_permissions(dir, perms).context("setting sessions directory permissions")?; + } + Ok(()) +} + +pub fn sessions_dir() -> Result { + let base = directories::BaseDirs::new() + .ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?; + Ok(base.config_dir().join("earl").join("browser-sessions")) +} + +pub fn session_file_path(session_id: &str) -> Result { + validate_session_id(session_id)?; + Ok(sessions_dir()?.join(format!("{session_id}.json"))) +} + +pub fn lock_file_path(session_id: &str) -> Result { + validate_session_id(session_id)?; + Ok(sessions_dir()?.join(format!("{session_id}.lock"))) +} + +pub fn is_pid_alive(pid: u32, _started_at: Option>) -> bool { + if pid == 0 { + // pid=0 means we don't know the PID (chromiumoxide doesn't expose it); + // return true so the caller falls through to the CDP probe. + return true; + } + #[cfg(unix)] + { + // Reject PIDs that would wrap to negative pid_t values (e.g. u32::MAX → -1). + // On Unix, kill(-1, 0) signals all processes and is not a PID existence check. + let pid_t = pid as libc::pid_t; + if pid_t <= 0 { + return false; + } + // kill(pid, 0) returns 0 if process exists and we can signal it, -1 otherwise. + let result = unsafe { libc::kill(pid_t, 0) }; + result == 0 + } + #[cfg(not(unix))] + { + // On non-unix, skip PID check; rely solely on CDP probe. + let _ = pid; + true + } +} + +pub async fn acquire_session_lock(session_id: &str) -> Result { + use fs4::tokio::AsyncFileExt; + + validate_session_id(session_id)?; + let dir = sessions_dir()?; + ensure_sessions_dir(&dir)?; + let lock_path = dir.join(format!("{session_id}.lock")); + + let file = tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&lock_path) + .await + .context("opening session lock file")?; + + match file.try_lock_exclusive() { + Ok(()) => Ok(file), + Err(_) => { + // Try to read the PID from the lock file content (best-effort). + let pid = tokio::fs::read_to_string(&lock_path) + .await + .unwrap_or_default() + .trim() + .parse::() + .unwrap_or(0); + Err(BrowserError::SessionLocked { + session_id: session_id.to_string(), + pid, + } + .into()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn round_trip_session_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("test.json"); + let orig = SessionFile { + pid: 12345, + websocket_url: "ws://127.0.0.1:9222/devtools/browser/xyz".into(), + target_id: "T42".into(), + started_at: Utc::now(), + last_used_at: Utc::now(), + interrupted: false, + }; + orig.save_to(&path).unwrap(); + let loaded = SessionFile::load_from(&path).unwrap(); + assert!(loaded.is_some()); + let loaded = loaded.unwrap(); + assert_eq!(loaded.pid, 12345); + assert_eq!(loaded.target_id, "T42"); + assert_eq!( + loaded.websocket_url, + "ws://127.0.0.1:9222/devtools/browser/xyz" + ); + assert!(!loaded.interrupted); + } + + #[test] + fn corrupt_json_returns_none() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("corrupt.json"); + std::fs::write(&path, b"not json {{{{").unwrap(); + assert!(SessionFile::load_from(&path).unwrap().is_none()); + } + + #[test] + fn missing_file_returns_none() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("nonexistent.json"); + assert!(SessionFile::load_from(&path).unwrap().is_none()); + } + + #[test] + fn delete_removes_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("session.json"); + std::fs::write(&path, b"{}").unwrap(); + SessionFile::delete(&path).unwrap(); + assert!(!path.exists()); + } + + #[test] + fn delete_nonexistent_is_ok() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("nonexistent.json"); + assert!(SessionFile::delete(&path).is_ok()); + } + + #[test] + fn session_dir_creates_with_correct_permissions() { + let dir = TempDir::new().unwrap(); + let sessions_dir = dir.path().join("sessions"); + ensure_sessions_dir(&sessions_dir).unwrap(); + assert!(sessions_dir.exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let meta = std::fs::metadata(&sessions_dir).unwrap(); + assert_eq!(meta.permissions().mode() & 0o777, 0o700); + } + } + + #[test] + fn is_pid_alive_returns_false_for_impossible_pid() { + // PID u32::MAX is virtually guaranteed not to exist + let alive = is_pid_alive(u32::MAX, None); + assert!(!alive); + } + + #[test] + fn validate_session_id_accepts_safe_ids() { + assert!(validate_session_id("my-session").is_ok()); + assert!(validate_session_id("session_123").is_ok()); + assert!(validate_session_id("ABC-def-0").is_ok()); + } + + #[test] + fn validate_session_id_rejects_path_traversal() { + assert!(validate_session_id("../../etc/passwd").is_err()); + assert!(validate_session_id("../sibling").is_err()); + assert!(validate_session_id("foo/bar").is_err()); + assert!(validate_session_id("foo\\bar").is_err()); + } + + #[test] + fn validate_session_id_rejects_empty() { + assert!(validate_session_id("").is_err()); + } +} diff --git a/crates/earl-protocol-browser/src/steps.rs b/crates/earl-protocol-browser/src/steps.rs new file mode 100644 index 0000000..77381bb --- /dev/null +++ b/crates/earl-protocol-browser/src/steps.rs @@ -0,0 +1,2061 @@ +use anyhow::Result; +use chromiumoxide::Page; +use serde_json::{Value, json}; + +use crate::accessibility::{AXNode, render_ax_tree}; +use crate::error::BrowserError; +use crate::schema::BrowserStep; + +// ── URL scheme validation ────────────────────────────────────────────────────── + +/// Validate that the given URL has an allowed scheme (http or https only). +/// Rejects file://, javascript:, data:, blob:, and any other scheme. +pub fn validate_url_scheme(url: &str) -> Result<()> { + let scheme = url.split(':').next().unwrap_or("").to_lowercase(); + match scheme.as_str() { + "http" | "https" => Ok(()), + other => Err(BrowserError::DisallowedScheme { + scheme: other.to_string(), + } + .into()), + } +} + +// ── File path validation ─────────────────────────────────────────────────────── + +/// Reject file paths that could escape the working directory. +/// +/// Only relative paths are permitted — absolute paths are rejected to prevent +/// writes to arbitrary filesystem locations. `..` components are also rejected +/// to block traversal out of the working directory. +fn validate_file_path(path: &str) -> Result<()> { + let p = std::path::Path::new(path); + if p.is_absolute() { + return Err(anyhow::anyhow!( + "file path \"{path}\" is not allowed: only relative paths are permitted" + )); + } + if p.components().any(|c| c == std::path::Component::ParentDir) { + return Err(anyhow::anyhow!( + "file path \"{path}\" is not allowed: path traversal (`..`) is not permitted" + )); + } + Ok(()) +} + +// ── Step execution context ───────────────────────────────────────────────────── + +pub struct StepContext<'a> { + pub page: &'a Page, + pub step_index: usize, + pub total_steps: usize, + pub global_timeout_ms: u64, +} + +// ── Main step loop ───────────────────────────────────────────────────────────── + +pub async fn execute_steps( + page: &Page, + steps: &[BrowserStep], + global_timeout_ms: u64, + on_failure_screenshot: bool, +) -> Result { + let total = steps.len(); + let mut last_result = json!({"ok": true}); + + for (i, step) in steps.iter().enumerate() { + let ctx = StepContext { + page, + step_index: i, + total_steps: total, + global_timeout_ms, + }; + let timeout_duration = std::time::Duration::from_millis(step.timeout_ms(global_timeout_ms)); + + let outcome = tokio::time::timeout(timeout_duration, execute_step(&ctx, step)).await; + + match outcome { + Ok(Ok(val)) => last_result = val, + Ok(Err(e)) => { + if step.is_optional() { + tracing::warn!( + "optional browser step {} ({}) failed (skipping): {e}", + i, + step.action_name() + ); + continue; + } + if on_failure_screenshot { + attempt_failure_screenshot(page).await; + } + return Err(e); + } + Err(_elapsed) => { + let timeout_ms = step.timeout_ms(global_timeout_ms); + let e: anyhow::Error = BrowserError::Timeout { + step: i, + action: step.action_name().into(), + timeout_ms, + } + .into(); + if step.is_optional() { + tracing::warn!( + "optional browser step {} ({}) timed out (skipping)", + i, + step.action_name() + ); + continue; + } + if on_failure_screenshot { + attempt_failure_screenshot(page).await; + } + return Err(e); + } + } + } + + Ok(last_result) +} + +/// Attempt to capture a diagnostic screenshot on step failure. +/// Errors here are silently swallowed so they don't mask the original error. +async fn attempt_failure_screenshot(page: &Page) { + let params = chromiumoxide::page::ScreenshotParams::builder().build(); + if let Ok(Ok(bytes)) = + tokio::time::timeout(std::time::Duration::from_secs(2), page.screenshot(params)).await + { + let path = std::env::temp_dir().join(format!( + "earl-browser-failure-{}.png", + chrono::Utc::now().timestamp_millis() + )); + if let Ok(()) = std::fs::write(&path, &bytes) { + // Restrict permissions so the diagnostic file is not world-readable. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + eprintln!("diagnostic screenshot saved: {}", path.display()); + } + } +} + +// ── Step dispatcher ──────────────────────────────────────────────────────────── + +pub async fn execute_step(ctx: &StepContext<'_>, step: &BrowserStep) -> Result { + match step { + BrowserStep::Navigate { + url, + expected_status, + .. + } => step_navigate(ctx, url, *expected_status).await, + BrowserStep::NavigateBack { .. } => step_navigate_back(ctx).await, + BrowserStep::NavigateForward { .. } => step_navigate_forward(ctx).await, + BrowserStep::Reload { .. } => step_reload(ctx).await, + BrowserStep::Snapshot { .. } => step_snapshot(ctx).await, + BrowserStep::Screenshot { + path, full_page, .. + } => step_screenshot(ctx, path.as_deref(), Some(*full_page)).await, + BrowserStep::Click { + r#ref, + selector, + double_click, + .. + } => step_click(ctx, r#ref.as_deref(), selector.as_deref(), *double_click).await, + BrowserStep::Hover { + r#ref, selector, .. + } => step_hover(ctx, r#ref.as_deref(), selector.as_deref()).await, + BrowserStep::Fill { + r#ref, + selector, + text, + submit, + .. + } => step_fill(ctx, r#ref.as_deref(), selector.as_deref(), text, *submit).await, + BrowserStep::SelectOption { + r#ref, + selector, + values, + .. + } => step_select_option(ctx, r#ref.as_deref(), selector.as_deref(), values).await, + BrowserStep::PressKey { key, .. } => step_press_key(ctx, key).await, + BrowserStep::Check { + r#ref, selector, .. + } => step_set_checked(ctx, r#ref.as_deref(), selector.as_deref(), true).await, + BrowserStep::Uncheck { + r#ref, selector, .. + } => step_set_checked(ctx, r#ref.as_deref(), selector.as_deref(), false).await, + BrowserStep::Drag { + start_ref, + start_selector, + end_ref, + end_selector, + .. + } => { + step_drag( + ctx, + start_ref.as_deref(), + start_selector.as_deref(), + end_ref.as_deref(), + end_selector.as_deref(), + ) + .await + } + BrowserStep::FillForm { fields, .. } => step_fill_form(ctx, fields).await, + BrowserStep::MouseMove { x, y, .. } => step_mouse_move(ctx, *x, *y).await, + BrowserStep::MouseClick { x, y, button, .. } => { + step_mouse_click(ctx, *x, *y, button.as_deref()).await + } + BrowserStep::MouseDrag { + start_x, + start_y, + end_x, + end_y, + .. + } => step_mouse_drag(ctx, *start_x, *start_y, *end_x, *end_y).await, + BrowserStep::MouseDown { button, .. } => { + step_mouse_button(ctx, button.as_deref(), true).await + } + BrowserStep::MouseUp { button, .. } => { + step_mouse_button(ctx, button.as_deref(), false).await + } + BrowserStep::MouseWheel { + delta_x, delta_y, .. + } => step_mouse_wheel(ctx, *delta_x, *delta_y).await, + + // ── Wait / Assert ────────────────────────────────────────────────── + BrowserStep::WaitFor { + time, + text, + text_gone, + timeout_ms, + .. + } => { + step_wait_for( + ctx, + *time, + text.as_deref(), + text_gone.as_deref(), + timeout_ms.unwrap_or(ctx.global_timeout_ms), + ) + .await + } + BrowserStep::VerifyElementVisible { + role, + accessible_name, + .. + } => step_verify_element_visible(ctx, role.as_deref(), accessible_name.as_deref()).await, + BrowserStep::VerifyTextVisible { text, .. } => step_verify_text_visible(ctx, text).await, + BrowserStep::VerifyListVisible { r#ref, items, .. } => { + if r#ref.is_some() { + return Err(anyhow::anyhow!( + "browser step {} (verify_list_visible): ref-based targeting is not yet \ + implemented; omit the ref field to match against the full page text", + ctx.step_index + )); + } + step_verify_list_visible(ctx, items).await + } + BrowserStep::VerifyValue { r#ref, value, .. } => { + if r#ref.is_some() { + return Err(anyhow::anyhow!( + "browser step {} (verify_value): ref-based targeting is not yet \ + implemented; omit the ref field to match against the active element", + ctx.step_index + )); + } + step_verify_value(ctx, value).await + } + + // ── JavaScript ──────────────────────────────────────────────────── + BrowserStep::Evaluate { function, .. } => step_evaluate(ctx, function).await, + BrowserStep::RunCode { code, .. } => step_run_code(ctx, code).await, + + // ── Tabs & Viewport ─────────────────────────────────────────────── + BrowserStep::Tabs { + operation, index, .. + } => step_tabs(ctx, operation, *index).await, + BrowserStep::Resize { width, height, .. } => step_resize(ctx, *width, *height).await, + BrowserStep::Close { .. } => step_close(ctx).await, + + // ── Network (stubs — not yet implemented) ──────────────────────── + BrowserStep::ConsoleMessages { .. } => { + Ok(json!({"messages": [], "note": "console_messages: not yet implemented"})) + } + BrowserStep::ConsoleClear { .. } => { + Ok(json!({"ok": true, "note": "console_clear: not yet implemented"})) + } + BrowserStep::NetworkRequests { .. } => { + Ok(json!({"requests": [], "note": "network_requests: not yet implemented"})) + } + BrowserStep::NetworkClear { .. } => { + Ok(json!({"ok": true, "note": "network_clear: not yet implemented"})) + } + BrowserStep::Route { .. } => Ok(json!({"ok": true, "note": "route: not yet implemented"})), + BrowserStep::RouteList { .. } => { + Ok(json!({"routes": [], "note": "route_list: not yet implemented"})) + } + BrowserStep::Unroute { .. } => { + Ok(json!({"ok": true, "note": "unroute: not yet implemented"})) + } + + // ── Cookies ─────────────────────────────────────────────────────── + BrowserStep::CookieList { domain, .. } => step_cookie_list(ctx, domain.as_deref()).await, + BrowserStep::CookieGet { name, .. } => step_cookie_get(ctx, name).await, + BrowserStep::CookieSet { + name, + value, + domain, + path, + expires, + http_only, + secure, + .. + } => { + step_cookie_set( + ctx, + name, + value, + domain.as_deref(), + path.as_deref(), + *expires, + *http_only, + *secure, + ) + .await + } + BrowserStep::CookieDelete { name, .. } => step_cookie_delete(ctx, name).await, + BrowserStep::CookieClear { .. } => step_cookie_clear(ctx).await, + + // ── Web Storage ─────────────────────────────────────────────────── + BrowserStep::LocalStorageGet { key, .. } => step_storage_get(ctx, "local", key).await, + BrowserStep::LocalStorageSet { key, value, .. } => { + step_storage_set(ctx, "local", key, value).await + } + BrowserStep::LocalStorageDelete { key, .. } => step_storage_delete(ctx, "local", key).await, + BrowserStep::LocalStorageClear { .. } => step_storage_clear(ctx, "local").await, + BrowserStep::SessionStorageGet { key, .. } => step_storage_get(ctx, "session", key).await, + BrowserStep::SessionStorageSet { key, value, .. } => { + step_storage_set(ctx, "session", key, value).await + } + BrowserStep::SessionStorageDelete { key, .. } => { + step_storage_delete(ctx, "session", key).await + } + BrowserStep::SessionStorageClear { .. } => step_storage_clear(ctx, "session").await, + BrowserStep::StorageState { path, .. } => step_storage_state(ctx, path.as_deref()).await, + BrowserStep::SetStorageState { path, .. } => step_set_storage_state(ctx, path).await, + + // ── File / Dialog / Download ────────────────────────────────────── + BrowserStep::FileUpload { .. } => { + Ok(json!({"ok": true, "note": "file_upload: not yet implemented"})) + } + BrowserStep::HandleDialog { + accept, + prompt_text, + .. + } => step_handle_dialog(ctx, *accept, prompt_text.as_deref()).await, + BrowserStep::Download { .. } => { + Ok(json!({"ok": true, "note": "download: not yet implemented"})) + } + + // ── Output / Recording ──────────────────────────────────────────── + BrowserStep::PdfSave { path, .. } => step_pdf_save(ctx, path.as_deref()).await, + BrowserStep::StartVideo { .. } => { + Ok(json!({"ok": true, "note": "video recording: not yet implemented"})) + } + BrowserStep::StopVideo { .. } => { + Ok(json!({"ok": true, "note": "video recording: not yet implemented"})) + } + BrowserStep::StartTracing { .. } => { + Ok(json!({"ok": true, "note": "tracing: not yet implemented"})) + } + BrowserStep::StopTracing { .. } => { + Ok(json!({"ok": true, "note": "tracing: not yet implemented"})) + } + BrowserStep::GenerateLocator { r#ref, .. } => step_generate_locator(ctx, r#ref).await, + } +} + +// ── Navigation ───────────────────────────────────────────────────────────────── + +async fn step_navigate( + ctx: &StepContext<'_>, + url: &str, + expected_status: Option, +) -> Result { + validate_url_scheme(url)?; + + ctx.page + .goto(url) + .await + .map_err(|e| anyhow::anyhow!("navigate to {url} failed: {e}"))?; + + if let Some(expected) = expected_status { + // Use the Performance Navigation Timing API to read the HTTP response + // status code after the navigation has settled. + let actual = ctx + .page + .evaluate("window.performance.getEntriesByType('navigation')[0]?.responseStatus ?? 0") + .await + .map_err(|e| anyhow::anyhow!("navigate status check failed: {e}"))? + .into_value::() + .ok() + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u16; + + if actual != expected { + return Err(BrowserError::AssertionFailed { + step: ctx.step_index, + action: "navigate".to_string(), + message: format!("expected HTTP status {expected}, got {actual} for {url}"), + } + .into()); + } + } + + Ok(json!({ "ok": true, "url": url })) +} + +async fn step_navigate_back(ctx: &StepContext<'_>) -> Result { + use chromiumoxide::cdp::browser_protocol::page::{ + GetNavigationHistoryParams, NavigateToHistoryEntryParams, + }; + + let history = ctx + .page + .execute(GetNavigationHistoryParams::default()) + .await + .map_err(|e| anyhow::anyhow!("get navigation history failed: {e}"))?; + + let current_index = history.result.current_index; + if current_index <= 0 { + // No history to go back to — treat as no-op. + return Ok(json!({ "ok": true })); + } + let target_index = (current_index - 1) as usize; + let entries = &history.result.entries; + if target_index >= entries.len() { + return Ok(json!({ "ok": true })); + } + let entry_id = entries[target_index].id; + + ctx.page + .execute(NavigateToHistoryEntryParams::new(entry_id)) + .await + .map_err(|e| anyhow::anyhow!("navigate back failed: {e}"))?; + + ctx.page + .wait_for_navigation() + .await + .map_err(|e| anyhow::anyhow!("wait for navigation after go-back failed: {e}"))?; + + Ok(json!({ "ok": true })) +} + +async fn step_navigate_forward(ctx: &StepContext<'_>) -> Result { + use chromiumoxide::cdp::browser_protocol::page::{ + GetNavigationHistoryParams, NavigateToHistoryEntryParams, + }; + + let history = ctx + .page + .execute(GetNavigationHistoryParams::default()) + .await + .map_err(|e| anyhow::anyhow!("get navigation history failed: {e}"))?; + + let current_index = history.result.current_index as usize; + let entries = &history.result.entries; + let next_index = current_index + 1; + if next_index >= entries.len() { + // No forward history — treat as no-op. + return Ok(json!({ "ok": true })); + } + let entry_id = entries[next_index].id; + + ctx.page + .execute(NavigateToHistoryEntryParams::new(entry_id)) + .await + .map_err(|e| anyhow::anyhow!("navigate forward failed: {e}"))?; + + ctx.page + .wait_for_navigation() + .await + .map_err(|e| anyhow::anyhow!("wait for navigation after go-forward failed: {e}"))?; + + Ok(json!({ "ok": true })) +} + +async fn step_reload(ctx: &StepContext<'_>) -> Result { + ctx.page + .reload() + .await + .map_err(|e| anyhow::anyhow!("reload failed: {e}"))?; + + Ok(json!({ "ok": true })) +} + +// ── Observation ──────────────────────────────────────────────────────────────── + +async fn step_snapshot(ctx: &StepContext<'_>) -> Result { + use chromiumoxide::cdp::browser_protocol::accessibility::GetFullAxTreeParams; + + let response = ctx + .page + .execute(GetFullAxTreeParams::default()) + .await + .map_err(|e| anyhow::anyhow!("get full AX tree failed: {e}"))?; + + let cdp_nodes = response.result.nodes; + + // Build a flat id→node map and then reconstruct the tree hierarchy. + use std::collections::HashMap; + + // Index nodes by their node_id. + let mut node_map: HashMap< + String, + &chromiumoxide::cdp::browser_protocol::accessibility::AxNode, + > = HashMap::new(); + for n in &cdp_nodes { + node_map.insert(n.node_id.inner().to_string(), n); + } + + // Convert a CDP AxNode into our simplified AXNode (recursively). + // The full tree can be large; we call the flat list version. + // CDP `GetFullAXTree` returns all nodes flat with parent_id references. + // Build the tree by finding root nodes (no parent_id) and recursing. + // A depth limit guards against stack overflow on pathologically deep trees. + const MAX_TREE_DEPTH: usize = 80; + fn build_tree( + node_id_str: &str, + node_map: &HashMap, + depth: usize, + ) -> Option { + if depth > MAX_TREE_DEPTH { + return None; + } + let cdp = node_map.get(node_id_str)?; + if cdp.ignored { + return None; + } + + let role = cdp + .role + .as_ref() + .and_then(|v| v.value.as_ref()) + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "unknown".to_string()); + + let name = cdp + .name + .as_ref() + .and_then(|v| v.value.as_ref()) + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_default(); + + let backend_node_id = cdp + .backend_dom_node_id + .as_ref() + .map(|id| *id.inner() as u64) + .unwrap_or(0); + + let children = cdp + .child_ids + .as_deref() + .unwrap_or(&[]) + .iter() + .filter_map(|child_id| build_tree(child_id.inner(), node_map, depth + 1)) + .collect(); + + Some(AXNode { + backend_node_id, + role, + name, + children, + }) + } + + // Collect root nodes (nodes with no parent or whose parent is not in the map). + let roots: Vec = cdp_nodes + .iter() + .filter(|n| { + !n.ignored + && n.parent_id + .as_ref() + .map(|pid| !node_map.contains_key(pid.inner())) + .unwrap_or(true) + }) + .filter_map(|n| build_tree(n.node_id.inner(), &node_map, 0)) + .collect(); + + let max_nodes = 5000; + let (markdown, refs) = render_ax_tree(&roots, max_nodes); + + Ok(json!({ + "text": markdown, + "refs": refs, + })) +} + +async fn step_screenshot( + ctx: &StepContext<'_>, + path: Option<&str>, + full_page: Option, +) -> Result { + if let Some(p) = path { + validate_file_path(p)?; + } + + // Use page.screenshot() to get bytes directly — avoids a temp-file round-trip + // and ensures no world-readable file is left behind when no path is given. + let params = chromiumoxide::page::ScreenshotParams::builder() + .full_page(full_page.unwrap_or(false)) + .build(); + + let bytes = ctx + .page + .screenshot(params) + .await + .map_err(|e| anyhow::anyhow!("screenshot failed: {e}"))?; + + if let Some(p) = path { + // User wants the file saved to disk. + tokio::fs::write(p, &bytes) + .await + .map_err(|e| anyhow::anyhow!("screenshot write {p}: {e}"))?; + Ok(json!({"path": p})) + } else { + // No path — return bytes as base64 only. + let data = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); + Ok(json!({"data": data})) + } +} + +// ── Interaction helpers ──────────────────────────────────────────────────────── + +/// Locate a page element by CSS selector. If a `ref_` is provided but no +/// selector, a helpful error is returned explaining that ref-based targeting +/// requires session mode (not yet implemented). If neither is provided, an +/// `ElementNotFound` error is returned. +async fn find_element_by_selector( + ctx: &StepContext<'_>, + selector: Option<&str>, + ref_: Option<&str>, + action: &str, +) -> Result { + let sel = match selector { + Some(s) => s, + None => { + if ref_.is_some() { + return Err(anyhow::anyhow!( + "browser step {} ({action}): 'ref' targeting requires session mode \ + (not yet available in this version); use 'selector' instead", + ctx.step_index + )); + } + return Err(BrowserError::ElementNotFound { + step: ctx.step_index, + action: action.to_string(), + selector: "(none provided)".to_string(), + completed: ctx.step_index, + total: ctx.total_steps, + } + .into()); + } + }; + + ctx.page.find_element(sel).await.map_err(|_| { + BrowserError::ElementNotFound { + step: ctx.step_index, + action: action.to_string(), + selector: sel.to_string(), + completed: ctx.step_index, + total: ctx.total_steps, + } + .into() + }) +} + +async fn step_click( + ctx: &StepContext<'_>, + ref_: Option<&str>, + selector: Option<&str>, + double_click: bool, +) -> Result { + let el = find_element_by_selector(ctx, selector, ref_, "click").await?; + el.click() + .await + .map_err(|e| anyhow::anyhow!("click failed: {e}"))?; + if double_click { + el.click() + .await + .map_err(|e| anyhow::anyhow!("double-click second click failed: {e}"))?; + // The two sequential .click() calls don't fire the dblclick DOM event + // that many frameworks listen to. Dispatch it explicitly. + el.call_js_fn( + "function() { this.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true})); }", + false, + ) + .await + .map_err(|e| anyhow::anyhow!("double-click dblclick event dispatch failed: {e}"))?; + } + Ok(json!({"ok": true})) +} + +async fn step_hover( + ctx: &StepContext<'_>, + ref_: Option<&str>, + selector: Option<&str>, +) -> Result { + let el = find_element_by_selector(ctx, selector, ref_, "hover").await?; + el.hover() + .await + .map_err(|e| anyhow::anyhow!("hover failed: {e}"))?; + Ok(json!({"ok": true})) +} + +async fn step_fill( + ctx: &StepContext<'_>, + ref_: Option<&str>, + selector: Option<&str>, + text: &str, + submit: Option, +) -> Result { + let el = find_element_by_selector(ctx, selector, ref_, "fill").await?; + el.click() + .await + .map_err(|e| anyhow::anyhow!("fill click: {e}"))?; + // Clear the existing value before typing. + el.call_js_fn( + "function() { this.value = ''; this.dispatchEvent(new Event('input', {bubbles: true})); }", + false, + ) + .await + .map_err(|e| anyhow::anyhow!("fill clear value: {e}"))?; + el.type_str(text) + .await + .map_err(|e| anyhow::anyhow!("fill type_str: {e}"))?; + if submit.unwrap_or(false) { + el.press_key("Enter") + .await + .map_err(|e| anyhow::anyhow!("fill submit: {e}"))?; + } + Ok(json!({"ok": true})) +} + +async fn step_select_option( + ctx: &StepContext<'_>, + _ref_: Option<&str>, + selector: Option<&str>, + values: &[String], +) -> Result { + let sel = selector.unwrap_or(""); + let values_json = serde_json::to_string(values)?; + let sel_json = serde_json::to_string(sel)?; + ctx.page + .evaluate(format!( + r#"(function() {{ + var el = document.querySelector({sel_json}); + if (!el) return false; + Array.from(el.options).forEach(function(o) {{ + o.selected = {values_json}.indexOf(o.value) !== -1; + }}); + el.dispatchEvent(new Event('change', {{bubbles: true}})); + return true; + }})()"#, + )) + .await + .map_err(|e| anyhow::anyhow!("select_option: {e}"))?; + Ok(json!({"ok": true})) +} + +async fn step_press_key(ctx: &StepContext<'_>, key: &str) -> Result { + use chromiumoxide::cdp::browser_protocol::input::{ + DispatchKeyEventParams, DispatchKeyEventType, + }; + use chromiumoxide::keys; + + let key_def = keys::get_key_definition(key) + .ok_or_else(|| anyhow::anyhow!("press_key: unknown key '{key}'"))?; + + let mut cmd = DispatchKeyEventParams::builder(); + + let key_down_type = if let Some(txt) = key_def.text { + cmd = cmd.text(txt); + DispatchKeyEventType::KeyDown + } else if key_def.key.len() == 1 { + cmd = cmd.text(key_def.key); + DispatchKeyEventType::KeyDown + } else { + DispatchKeyEventType::RawKeyDown + }; + + cmd = cmd + .key(key_def.key) + .code(key_def.code) + .windows_virtual_key_code(key_def.key_code) + .native_virtual_key_code(key_def.key_code); + + ctx.page + .execute(cmd.clone().r#type(key_down_type).build().unwrap()) + .await + .map_err(|e| anyhow::anyhow!("press_key key_down: {e}"))?; + ctx.page + .execute(cmd.r#type(DispatchKeyEventType::KeyUp).build().unwrap()) + .await + .map_err(|e| anyhow::anyhow!("press_key key_up: {e}"))?; + + Ok(json!({"ok": true})) +} + +async fn step_set_checked( + ctx: &StepContext<'_>, + ref_: Option<&str>, + selector: Option<&str>, + checked: bool, +) -> Result { + let action = if checked { "check" } else { "uncheck" }; + let el = find_element_by_selector(ctx, selector, ref_, action).await?; + // Only click if the current state differs from the desired state. + let result = el + .call_js_fn("function() { return this.checked; }", false) + .await + .map_err(|e| anyhow::anyhow!("set_checked get state: {e}"))?; + let current: Value = result.result.value.unwrap_or(Value::Bool(false)); + if current.as_bool() != Some(checked) { + el.click() + .await + .map_err(|e| anyhow::anyhow!("set_checked click: {e}"))?; + } + Ok(json!({"ok": true})) +} + +async fn step_drag( + ctx: &StepContext<'_>, + _start_ref: Option<&str>, + start_selector: Option<&str>, + _end_ref: Option<&str>, + end_selector: Option<&str>, +) -> Result { + let start_sel = start_selector.unwrap_or(""); + let end_sel = end_selector.unwrap_or(""); + let start_json = serde_json::to_string(start_sel)?; + let end_json = serde_json::to_string(end_sel)?; + ctx.page + .evaluate(format!( + r#"(function() {{ + var src = document.querySelector({start_json}); + var dst = document.querySelector({end_json}); + if (!src || !dst) return false; + src.dispatchEvent(new DragEvent('dragstart', {{bubbles: true, cancelable: true}})); + dst.dispatchEvent(new DragEvent('dragenter', {{bubbles: true, cancelable: true}})); + dst.dispatchEvent(new DragEvent('dragover', {{bubbles: true, cancelable: true}})); + dst.dispatchEvent(new DragEvent('drop', {{bubbles: true, cancelable: true}})); + src.dispatchEvent(new DragEvent('dragend', {{bubbles: true, cancelable: true}})); + return true; + }})()"#, + )) + .await + .map_err(|e| anyhow::anyhow!("drag: {e}"))?; + Ok(json!({"ok": true})) +} + +async fn step_fill_form(ctx: &StepContext<'_>, fields: &[Value]) -> Result { + for field in fields { + let ref_ = field.get("ref").and_then(|v| v.as_str()); + let selector = field.get("selector").and_then(|v| v.as_str()); + let value = field.get("value").and_then(|v| v.as_str()).unwrap_or(""); + let type_ = field + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("textbox"); + match type_ { + "checkbox" => { + let checked = value == "true" || value == "1"; + step_set_checked(ctx, ref_, selector, checked).await?; + } + _ => { + step_fill(ctx, ref_, selector, value, None).await?; + } + } + } + Ok(json!({"ok": true})) +} + +// ── Mouse coordinate steps ───────────────────────────────────────────────────── + +async fn step_mouse_move(ctx: &StepContext<'_>, x: f64, y: f64) -> Result { + use chromiumoxide::cdp::browser_protocol::input::{ + DispatchMouseEventParams, DispatchMouseEventType, + }; + ctx.page + .execute( + DispatchMouseEventParams::builder() + .r#type(DispatchMouseEventType::MouseMoved) + .x(x) + .y(y) + .build() + .unwrap(), + ) + .await + .map_err(|e| anyhow::anyhow!("mouse_move: {e}"))?; + Ok(json!({"ok": true})) +} + +async fn step_mouse_click( + ctx: &StepContext<'_>, + x: f64, + y: f64, + button: Option<&str>, +) -> Result { + use chromiumoxide::cdp::browser_protocol::input::{ + DispatchMouseEventParams, DispatchMouseEventType, + }; + let mb = parse_mouse_button(button); + ctx.page + .execute( + DispatchMouseEventParams::builder() + .r#type(DispatchMouseEventType::MousePressed) + .x(x) + .y(y) + .button(mb.clone()) + .click_count(1i64) + .build() + .unwrap(), + ) + .await + .map_err(|e| anyhow::anyhow!("mouse_click pressed: {e}"))?; + ctx.page + .execute( + DispatchMouseEventParams::builder() + .r#type(DispatchMouseEventType::MouseReleased) + .x(x) + .y(y) + .button(mb) + .click_count(1i64) + .build() + .unwrap(), + ) + .await + .map_err(|e| anyhow::anyhow!("mouse_click released: {e}"))?; + Ok(json!({"ok": true})) +} + +async fn step_mouse_drag( + ctx: &StepContext<'_>, + start_x: f64, + start_y: f64, + end_x: f64, + end_y: f64, +) -> Result { + use chromiumoxide::cdp::browser_protocol::input::{ + DispatchMouseEventParams, DispatchMouseEventType, + }; + ctx.page + .execute( + DispatchMouseEventParams::builder() + .r#type(DispatchMouseEventType::MousePressed) + .x(start_x) + .y(start_y) + .build() + .unwrap(), + ) + .await + .map_err(|e| anyhow::anyhow!("mouse_drag pressed: {e}"))?; + ctx.page + .execute( + DispatchMouseEventParams::builder() + .r#type(DispatchMouseEventType::MouseMoved) + .x(end_x) + .y(end_y) + .build() + .unwrap(), + ) + .await + .map_err(|e| anyhow::anyhow!("mouse_drag moved: {e}"))?; + ctx.page + .execute( + DispatchMouseEventParams::builder() + .r#type(DispatchMouseEventType::MouseReleased) + .x(end_x) + .y(end_y) + .build() + .unwrap(), + ) + .await + .map_err(|e| anyhow::anyhow!("mouse_drag released: {e}"))?; + Ok(json!({"ok": true})) +} + +async fn step_mouse_button( + ctx: &StepContext<'_>, + button: Option<&str>, + pressed: bool, +) -> Result { + use chromiumoxide::cdp::browser_protocol::input::{ + DispatchMouseEventParams, DispatchMouseEventType, + }; + // Use the centre of the viewport as the default position. + let pos: Value = ctx + .page + .evaluate("({x: window.innerWidth / 2, y: window.innerHeight / 2})") + .await + .map_err(|e| anyhow::anyhow!("mouse_button get position: {e}"))? + .into_value()?; + let x = pos["x"].as_f64().unwrap_or(400.0); + let y = pos["y"].as_f64().unwrap_or(300.0); + let mb = parse_mouse_button(button); + let evt_type = if pressed { + DispatchMouseEventType::MousePressed + } else { + DispatchMouseEventType::MouseReleased + }; + ctx.page + .execute( + DispatchMouseEventParams::builder() + .r#type(evt_type) + .x(x) + .y(y) + .button(mb) + .build() + .unwrap(), + ) + .await + .map_err(|e| anyhow::anyhow!("mouse_button: {e}"))?; + Ok(json!({"ok": true})) +} + +async fn step_mouse_wheel(ctx: &StepContext<'_>, delta_x: f64, delta_y: f64) -> Result { + use chromiumoxide::cdp::browser_protocol::input::{ + DispatchMouseEventParams, DispatchMouseEventType, + }; + let pos: Value = ctx + .page + .evaluate("({x: window.innerWidth / 2, y: window.innerHeight / 2})") + .await + .map_err(|e| anyhow::anyhow!("mouse_wheel get position: {e}"))? + .into_value()?; + let x = pos["x"].as_f64().unwrap_or(400.0); + let y = pos["y"].as_f64().unwrap_or(300.0); + ctx.page + .execute( + DispatchMouseEventParams::builder() + .r#type(DispatchMouseEventType::MouseWheel) + .x(x) + .y(y) + .delta_x(delta_x) + .delta_y(delta_y) + .build() + .unwrap(), + ) + .await + .map_err(|e| anyhow::anyhow!("mouse_wheel: {e}"))?; + Ok(json!({"ok": true})) +} + +// ── Wait / Assert ─────────────────────────────────────────────────────────── + +async fn step_wait_for( + ctx: &StepContext<'_>, + time: Option, + text: Option<&str>, + text_gone: Option<&str>, + timeout_ms: u64, +) -> Result { + if let Some(secs) = time { + tokio::time::sleep(std::time::Duration::from_secs_f64(secs)).await; + } + + if text.is_none() && text_gone.is_none() { + return Ok(json!({"ok": true})); + } + + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms.max(200)); + + loop { + let body_text: Value = ctx + .page + .evaluate("document.body ? document.body.innerText : ''") + .await + .map_err(|e| anyhow::anyhow!("wait_for evaluate: {e}"))? + .into_value()?; + let body = body_text.as_str().unwrap_or(""); + + if let Some(t) = text { + if body.contains(t) { + // text found — check text_gone too + if let Some(tg) = text_gone { + if !body.contains(tg) { + return Ok(json!({"ok": true})); + } + } else { + return Ok(json!({"ok": true})); + } + } + } else if let Some(tg) = text_gone + && !body.contains(tg) + { + return Ok(json!({"ok": true})); + } + + // Check deadline before sleeping so we never overshoot by a full poll interval. + let now = tokio::time::Instant::now(); + if now >= deadline { + return Err(BrowserError::Timeout { + step: ctx.step_index, + action: "wait_for".into(), + timeout_ms, + } + .into()); + } + + // Sleep for at most the remaining time to avoid overshooting the deadline. + let remaining = deadline - now; + tokio::time::sleep(remaining.min(std::time::Duration::from_millis(200))).await; + } +} + +async fn step_verify_text_visible(ctx: &StepContext<'_>, text: &str) -> Result { + let body_text: Value = ctx + .page + .evaluate("document.body ? document.body.innerText : ''") + .await + .map_err(|e| anyhow::anyhow!("verify_text_visible evaluate: {e}"))? + .into_value()?; + let body = body_text.as_str().unwrap_or(""); + if body.contains(text) { + Ok(json!({"ok": true, "text": text})) + } else { + Err(BrowserError::AssertionFailed { + step: ctx.step_index, + action: "verify_text_visible".into(), + message: format!("text not found in page: {text:?}"), + } + .into()) + } +} + +async fn step_verify_list_visible(ctx: &StepContext<'_>, items: &[String]) -> Result { + let body_text: Value = ctx + .page + .evaluate("document.body ? document.body.innerText : ''") + .await + .map_err(|e| anyhow::anyhow!("verify_list_visible evaluate: {e}"))? + .into_value()?; + let body = body_text.as_str().unwrap_or(""); + let mut missing = Vec::new(); + for item in items { + if !body.contains(item.as_str()) { + missing.push(item.as_str()); + } + } + if missing.is_empty() { + Ok(json!({"ok": true})) + } else { + Err(BrowserError::AssertionFailed { + step: ctx.step_index, + action: "verify_list_visible".into(), + message: format!("items not found in page: {:?}", missing), + } + .into()) + } +} + +async fn step_verify_element_visible( + ctx: &StepContext<'_>, + role: Option<&str>, + accessible_name: Option<&str>, +) -> Result { + // Build a simple JS check using aria attributes. + let role_json = serde_json::to_string(role.unwrap_or(""))?; + let name_json = serde_json::to_string(accessible_name.unwrap_or(""))?; + let result: Value = ctx + .page + .evaluate(format!( + r#"(function() {{ + var role = {role_json}; + var name = {name_json}; + var els = document.querySelectorAll('*'); + for (var i = 0; i < els.length; i++) {{ + var el = els[i]; + var elRole = el.getAttribute('role') || el.tagName.toLowerCase(); + var elName = el.getAttribute('aria-label') || el.textContent || ''; + if ((role === '' || elRole === role) && (name === '' || elName.trim().indexOf(name) !== -1)) {{ + return true; + }} + }} + return false; + }})()"#, + )) + .await + .map_err(|e| anyhow::anyhow!("verify_element_visible evaluate: {e}"))? + .into_value()?; + + if result.as_bool().unwrap_or(false) { + Ok(json!({"ok": true})) + } else { + Err(BrowserError::AssertionFailed { + step: ctx.step_index, + action: "verify_element_visible".into(), + message: format!( + "element not found — role={:?} name={:?}", + role, accessible_name + ), + } + .into()) + } +} + +async fn step_verify_value(ctx: &StepContext<'_>, expected: &str) -> Result { + // Evaluate the value of the currently focused element (or the first input). + let expected_json = serde_json::to_string(expected)?; + let result: Value = ctx + .page + .evaluate( + r#"(function() { + var el = document.activeElement || document.querySelector('input,textarea,select'); + if (!el) return null; + return el.value !== undefined ? el.value : el.textContent; + })()"#, + ) + .await + .map_err(|e| anyhow::anyhow!("verify_value evaluate: {e}"))? + .into_value()?; + + let actual = result.as_str().unwrap_or(""); + if actual == expected { + Ok(json!({"ok": true, "value": actual})) + } else { + Err(BrowserError::AssertionFailed { + step: ctx.step_index, + action: "verify_value".into(), + message: format!("expected value {expected_json}, got {:?}", actual), + } + .into()) + } +} + +// ── JavaScript ────────────────────────────────────────────────────────────── + +async fn step_evaluate(ctx: &StepContext<'_>, function: &str) -> Result { + let result: Value = ctx + .page + .evaluate(function) + .await + .map_err(|e| anyhow::anyhow!("evaluate: {e}"))? + .into_value()?; + Ok(json!({"value": result})) +} + +async fn step_run_code(ctx: &StepContext<'_>, code: &str) -> Result { + let wrapped = format!("(async () => {{ {} }})()", code); + let result: Value = ctx + .page + .evaluate(wrapped) + .await + .map_err(|e| anyhow::anyhow!("run_code: {e}"))? + .into_value()?; + Ok(json!({"value": result})) +} + +// ── Tabs & Viewport ───────────────────────────────────────────────────────── + +async fn step_tabs(ctx: &StepContext<'_>, operation: &str, _index: Option) -> Result { + match operation { + "list" => { + let url: Value = ctx + .page + .evaluate("window.location.href") + .await + .map_err(|e| anyhow::anyhow!("tabs list: {e}"))? + .into_value()?; + Ok(json!({"tabs": [{"url": url, "index": 0, "active": true}]})) + } + _ => Ok(json!({ + "ok": true, + "note": "full tab management (new/select/close) requires session mode" + })), + } +} + +async fn step_resize(ctx: &StepContext<'_>, width: u32, height: u32) -> Result { + use chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams; + ctx.page + .execute(SetDeviceMetricsOverrideParams::new( + width as i64, + height as i64, + 1.0_f64, + false, + )) + .await + .map_err(|e| anyhow::anyhow!("resize: {e}"))?; + Ok(json!({"ok": true, "width": width, "height": height})) +} + +async fn step_close(ctx: &StepContext<'_>) -> Result { + use chromiumoxide::cdp::browser_protocol::page::CloseParams; + ctx.page + .execute(CloseParams::default()) + .await + .map_err(|e| anyhow::anyhow!("close: {e}"))?; + Ok(json!({"ok": true})) +} + +// ── Cookies ───────────────────────────────────────────────────────────────── + +async fn step_cookie_list(ctx: &StepContext<'_>, domain: Option<&str>) -> Result { + use chromiumoxide::cdp::browser_protocol::network::GetCookiesParams; + let result = ctx + .page + .execute(GetCookiesParams::default()) + .await + .map_err(|e| anyhow::anyhow!("cookie_list: {e}"))?; + let cookies: Vec = result + .result + .cookies + .iter() + .filter(|c| domain.is_none_or(|d| c.domain.contains(d))) + .map(|c| { + json!({ + "name": c.name, + "value": c.value, + "domain": c.domain, + "path": c.path, + "expires": c.expires, + "http_only": c.http_only, + "secure": c.secure, + "session": c.session, + }) + }) + .collect(); + Ok(json!({"cookies": cookies})) +} + +async fn step_cookie_get(ctx: &StepContext<'_>, name: &str) -> Result { + use chromiumoxide::cdp::browser_protocol::network::GetCookiesParams; + let result = ctx + .page + .execute(GetCookiesParams::default()) + .await + .map_err(|e| anyhow::anyhow!("cookie_get: {e}"))?; + let cookie = result.result.cookies.iter().find(|c| c.name == name); + match cookie { + Some(c) => Ok(json!({ + "name": c.name, + "value": c.value, + "domain": c.domain, + "path": c.path, + "expires": c.expires, + "http_only": c.http_only, + "secure": c.secure, + })), + None => Ok(json!({"name": name, "value": null})), + } +} + +#[allow(clippy::too_many_arguments)] +async fn step_cookie_set( + ctx: &StepContext<'_>, + name: &str, + value: &str, + domain: Option<&str>, + path: Option<&str>, + expires: Option, + http_only: bool, + secure: bool, +) -> Result { + use chromiumoxide::cdp::browser_protocol::network::SetCookieParams; + let mut params = SetCookieParams::new(name, value); + if let Some(d) = domain { + params.domain = Some(d.to_string()); + } + if let Some(p) = path { + params.path = Some(p.to_string()); + } + if let Some(e) = expires { + use chromiumoxide::cdp::browser_protocol::network::TimeSinceEpoch; + params.expires = Some(TimeSinceEpoch::new(e)); + } + params.http_only = Some(http_only); + params.secure = Some(secure); + ctx.page + .execute(params) + .await + .map_err(|e| anyhow::anyhow!("cookie_set: {e}"))?; + Ok(json!({"ok": true, "name": name})) +} + +async fn step_cookie_delete(ctx: &StepContext<'_>, name: &str) -> Result { + use chromiumoxide::cdp::browser_protocol::network::DeleteCookiesParams; + // CDP requires at least one of `url` or `domain`; use the current page URL. + let url = ctx.page.url().await.ok().flatten(); + let mut params = DeleteCookiesParams::new(name); + params.url = url; + ctx.page + .execute(params) + .await + .map_err(|e| anyhow::anyhow!("cookie_delete: {e}"))?; + Ok(json!({"ok": true, "name": name})) +} + +async fn step_cookie_clear(ctx: &StepContext<'_>) -> Result { + use chromiumoxide::cdp::browser_protocol::network::ClearBrowserCookiesParams; + ctx.page + .execute(ClearBrowserCookiesParams::default()) + .await + .map_err(|e| anyhow::anyhow!("cookie_clear: {e}"))?; + Ok(json!({"ok": true})) +} + +// ── Web Storage ───────────────────────────────────────────────────────────── + +/// `kind` is either `"local"` or `"session"`. +fn storage_js_obj(kind: &str) -> &'static str { + if kind == "session" { + "sessionStorage" + } else { + "localStorage" + } +} + +async fn step_storage_get(ctx: &StepContext<'_>, kind: &str, key: &str) -> Result { + let key_json = serde_json::to_string(key)?; + let obj = storage_js_obj(kind); + let val: Value = ctx + .page + .evaluate(format!("{obj}.getItem({key_json})")) + .await + .map_err(|e| anyhow::anyhow!("storage_get: {e}"))? + .into_value()?; + Ok(json!({"key": key, "value": val})) +} + +async fn step_storage_set( + ctx: &StepContext<'_>, + kind: &str, + key: &str, + value: &str, +) -> Result { + let key_json = serde_json::to_string(key)?; + let val_json = serde_json::to_string(value)?; + let obj = storage_js_obj(kind); + ctx.page + .evaluate(format!("{obj}.setItem({key_json}, {val_json})")) + .await + .map_err(|e| anyhow::anyhow!("storage_set: {e}"))?; + Ok(json!({"ok": true, "key": key})) +} + +async fn step_storage_delete(ctx: &StepContext<'_>, kind: &str, key: &str) -> Result { + let key_json = serde_json::to_string(key)?; + let obj = storage_js_obj(kind); + ctx.page + .evaluate(format!("{obj}.removeItem({key_json})")) + .await + .map_err(|e| anyhow::anyhow!("storage_delete: {e}"))?; + Ok(json!({"ok": true, "key": key})) +} + +async fn step_storage_clear(ctx: &StepContext<'_>, kind: &str) -> Result { + let obj = storage_js_obj(kind); + ctx.page + .evaluate(format!("{obj}.clear()")) + .await + .map_err(|e| anyhow::anyhow!("storage_clear: {e}"))?; + Ok(json!({"ok": true})) +} + +async fn step_storage_state(ctx: &StepContext<'_>, path: Option<&str>) -> Result { + use chromiumoxide::cdp::browser_protocol::network::GetCookiesParams; + + // Gather cookies. + let cookie_result = ctx + .page + .execute(GetCookiesParams::default()) + .await + .map_err(|e| anyhow::anyhow!("storage_state cookies: {e}"))?; + let cookies: Vec = cookie_result + .result + .cookies + .iter() + .map(|c| { + json!({ + "name": c.name, + "value": c.value, + "domain": c.domain, + "path": c.path, + "expires": c.expires, + "http_only": c.http_only, + "secure": c.secure, + }) + }) + .collect(); + + // Gather localStorage. + let ls: Value = ctx + .page + .evaluate( + r#"(function() { + var out = {}; + for (var i = 0; i < localStorage.length; i++) { + var k = localStorage.key(i); + out[k] = localStorage.getItem(k); + } + return out; + })()"#, + ) + .await + .map_err(|e| anyhow::anyhow!("storage_state localStorage: {e}"))? + .into_value()?; + + let state = json!({"cookies": cookies, "local_storage": ls}); + + if let Some(p) = path { + validate_file_path(p)?; + let bytes = serde_json::to_vec_pretty(&state)?; + tokio::fs::write(p, &bytes) + .await + .map_err(|e| anyhow::anyhow!("storage_state write {p}: {e}"))?; + Ok(json!({"path": p, "cookies": cookies.len()})) + } else { + Ok(state) + } +} + +async fn step_set_storage_state(ctx: &StepContext<'_>, path: &str) -> Result { + validate_file_path(path)?; + let bytes = tokio::fs::read(path) + .await + .map_err(|e| anyhow::anyhow!("set_storage_state read {path}: {e}"))?; + let state: Value = serde_json::from_slice(&bytes) + .map_err(|e| anyhow::anyhow!("set_storage_state parse: {e}"))?; + + // Restore cookies. + if let Some(cookies) = state.get("cookies").and_then(|v| v.as_array()) { + use chromiumoxide::cdp::browser_protocol::network::SetCookieParams; + for c in cookies { + let name = c.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let value = c.get("value").and_then(|v| v.as_str()).unwrap_or(""); + let mut params = SetCookieParams::new(name, value); + if let Some(d) = c.get("domain").and_then(|v| v.as_str()) { + params.domain = Some(d.to_string()); + } + if let Some(p) = c.get("path").and_then(|v| v.as_str()) { + params.path = Some(p.to_string()); + } + if let Some(e) = c.get("expires").and_then(|v| v.as_f64()) { + use chromiumoxide::cdp::browser_protocol::network::TimeSinceEpoch; + params.expires = Some(TimeSinceEpoch::new(e)); + } + if let Some(ho) = c.get("http_only").and_then(|v| v.as_bool()) { + params.http_only = Some(ho); + } + if let Some(s) = c.get("secure").and_then(|v| v.as_bool()) { + params.secure = Some(s); + } + ctx.page + .execute(params) + .await + .map_err(|e| anyhow::anyhow!("set_storage_state set cookie: {e}"))?; + } + } + + // Restore localStorage. + if let Some(ls) = state.get("local_storage").and_then(|v| v.as_object()) { + let entries_json = serde_json::to_string(ls)?; + ctx.page + .evaluate(format!( + r#"(function(entries) {{ + localStorage.clear(); + for (var k in entries) {{ + localStorage.setItem(k, entries[k]); + }} + }})({entries_json})"#, + )) + .await + .map_err(|e| anyhow::anyhow!("set_storage_state localStorage: {e}"))?; + } + + Ok(json!({"ok": true, "path": path})) +} + +// ── Dialog ─────────────────────────────────────────────────────────────────── + +async fn step_handle_dialog( + ctx: &StepContext<'_>, + accept: bool, + prompt_text: Option<&str>, +) -> Result { + use chromiumoxide::cdp::browser_protocol::page::{ + EventJavascriptDialogOpening, HandleJavaScriptDialogParams, + }; + use futures::StreamExt; + + // Subscribe to dialog-opening events BEFORE attempting to handle, so we + // don't miss a dialog that fires between the check and the dismiss call. + let mut dialog_events = ctx + .page + .event_listener::() + .await + .map_err(|e| anyhow::anyhow!("handle_dialog: subscribe: {e}"))?; + + let mut params = HandleJavaScriptDialogParams::new(accept); + if let Some(t) = prompt_text { + params.prompt_text = Some(t.to_string()); + } + + // Try to dismiss a dialog that is already open. + if ctx.page.execute(params.clone()).await.is_ok() { + return Ok(json!({"ok": true, "accept": accept})); + } + + // Wait up to the global timeout for a dialog to appear. + tokio::time::timeout( + std::time::Duration::from_millis(ctx.global_timeout_ms), + dialog_events.next(), + ) + .await + .map_err(|_| anyhow::anyhow!("handle_dialog: timed out waiting for dialog to appear"))?; + + // Dismiss the now-pending dialog. + ctx.page + .execute(params) + .await + .map_err(|e| anyhow::anyhow!("handle_dialog: {e}"))?; + + Ok(json!({"ok": true, "accept": accept})) +} + +// ── PDF ────────────────────────────────────────────────────────────────────── + +async fn step_pdf_save(ctx: &StepContext<'_>, path: Option<&str>) -> Result { + use chromiumoxide::cdp::browser_protocol::page::PrintToPdfParams; + + if let Some(p) = path { + validate_file_path(p)?; + } + + let result = ctx + .page + .execute(PrintToPdfParams::default()) + .await + .map_err(|e| anyhow::anyhow!("pdf_save: {e}"))?; + + // result.result.data is a Binary wrapping a base64 string. + let b64: String = result.result.data.into(); + let pdf_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64.trim()) + .map_err(|e| anyhow::anyhow!("pdf_save base64 decode: {e}"))?; + + if let Some(p) = path { + // User wants the file saved to disk — no temp file needed. + tokio::fs::write(p, &pdf_bytes) + .await + .map_err(|e| anyhow::anyhow!("pdf_save write {p}: {e}"))?; + Ok(json!({"path": p, "size": pdf_bytes.len()})) + } else { + // No path given — return bytes as base64; nothing written to disk. + let data = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &pdf_bytes); + Ok(json!({"data": data, "size": pdf_bytes.len()})) + } +} + +// ── GenerateLocator ───────────────────────────────────────────────────────── + +async fn step_generate_locator(_ctx: &StepContext<'_>, ref_: &str) -> Result { + // Validate ref_ contains only safe characters for a CSS attribute value + if !ref_ + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return Err(anyhow::anyhow!( + "generate_locator: ref '{ref_}' contains characters unsafe for CSS attribute selector" + )); + } + // Without a live ref→selector mapping, we return a CSS attribute selector + // based on the ref ID. In session mode this would resolve to a precise selector. + let locator = format!("[data-ref=\"{ref_}\"]"); + Ok(json!({"locator": locator, "ref": ref_})) +} + +/// Parse an optional button string into a `MouseButton` enum value. +fn parse_mouse_button( + button: Option<&str>, +) -> chromiumoxide::cdp::browser_protocol::input::MouseButton { + use chromiumoxide::cdp::browser_protocol::input::MouseButton; + match button { + Some("right") => MouseButton::Right, + Some("middle") => MouseButton::Middle, + Some("back") => MouseButton::Back, + Some("forward") => MouseButton::Forward, + _ => MouseButton::Left, + } +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disallowed_scheme_rejected() { + assert!(validate_url_scheme("file:///etc/passwd").is_err()); + let err = validate_url_scheme("file:///etc/passwd").unwrap_err(); + assert!(err.to_string().contains("file")); + assert!(err.to_string().contains("http")); + } + + #[test] + fn javascript_uri_rejected() { + assert!(validate_url_scheme("javascript:alert(1)").is_err()); + } + + #[test] + fn data_uri_rejected() { + assert!(validate_url_scheme("data:text/html,

test

").is_err()); + } + + #[test] + fn validate_file_path_rejects_traversal() { + assert!(validate_file_path("../secret.txt").is_err()); + assert!(validate_file_path("/tmp/../../etc/passwd").is_err()); + assert!(validate_file_path("foo/../bar").is_err()); + } + + #[test] + fn validate_file_path_rejects_absolute_paths() { + assert!(validate_file_path("/tmp/output.png").is_err()); + assert!(validate_file_path("/etc/passwd").is_err()); + assert!(validate_file_path("/home/user/.bashrc").is_err()); + } + + #[test] + fn validate_file_path_accepts_relative_paths() { + assert!(validate_file_path("relative/path.pdf").is_ok()); + assert!(validate_file_path("file.json").is_ok()); + assert!(validate_file_path("output/screenshot.png").is_ok()); + } + + #[test] + fn http_scheme_allowed() { + assert!(validate_url_scheme("https://example.com").is_ok()); + assert!(validate_url_scheme("http://example.com/path?q=1").is_ok()); + } + + #[test] + fn blob_uri_rejected() { + assert!(validate_url_scheme("blob:https://example.com/abc").is_err()); + } + + // ── Tests for new step helpers (no browser required) ───────────────────── + + #[test] + fn storage_js_obj_returns_correct_object() { + assert_eq!(storage_js_obj("local"), "localStorage"); + assert_eq!(storage_js_obj("session"), "sessionStorage"); + // Anything that isn't "session" defaults to localStorage. + assert_eq!(storage_js_obj("other"), "localStorage"); + } + + #[test] + fn generate_locator_produces_data_ref_selector() { + // The function is async but we can verify the locator format logic + // by checking the string that would be produced. + let ref_id = "e42"; + let expected = format!("[data-ref=\"{ref_id}\"]"); + assert_eq!(expected, "[data-ref=\"e42\"]"); + } + + #[test] + fn generate_locator_rejects_unsafe_ref() { + // Characters that would break a CSS attribute selector must be rejected. + let unsafe_refs = [ + "e\"42", // double-quote breaks the attribute value + "e]42", // bracket closes the selector early + "e[42", // bracket opens a nested selector + "e 42", // space is not a valid identifier character + "e<42>", // angle brackets + "e;42", // semicolons + ]; + for bad in &unsafe_refs { + let is_safe = bad + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_'); + assert!( + !is_safe, + "expected '{bad}' to be rejected as unsafe for CSS attribute selector" + ); + } + + // Safe refs must pass the same check. + let safe_refs = ["e42", "my-ref", "some_id", "Abc123", "a-b_c"]; + for good in &safe_refs { + let is_safe = good + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_'); + assert!( + is_safe, + "expected '{good}' to be accepted as safe for CSS attribute selector" + ); + } + } + + #[test] + fn cookie_set_params_new_api() { + use chromiumoxide::cdp::browser_protocol::network::SetCookieParams; + let params = SetCookieParams::new("session", "abc123"); + assert_eq!(params.name, "session"); + assert_eq!(params.value, "abc123"); + assert!(params.domain.is_none()); + } + + #[test] + fn cookie_delete_params_new_api() { + use chromiumoxide::cdp::browser_protocol::network::DeleteCookiesParams; + let params = DeleteCookiesParams::new("session"); + assert_eq!(params.name, "session"); + } + + #[test] + fn time_since_epoch_new_api() { + use chromiumoxide::cdp::browser_protocol::network::TimeSinceEpoch; + let t = TimeSinceEpoch::new(1_700_000_000.0_f64); + assert_eq!(*t.inner(), 1_700_000_000.0_f64); + } + + #[test] + fn resize_params_new_api() { + use chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams; + let params = SetDeviceMetricsOverrideParams::new(1280_i64, 720_i64, 1.0_f64, false); + assert_eq!(params.width, 1280); + assert_eq!(params.height, 720); + assert!(!params.mobile); + } + + #[test] + fn handle_dialog_params_new_api() { + use chromiumoxide::cdp::browser_protocol::page::HandleJavaScriptDialogParams; + let params = HandleJavaScriptDialogParams::new(true); + assert!(params.accept); + assert!(params.prompt_text.is_none()); + } + + #[test] + fn wait_for_time_only_does_not_poll() { + // WaitFor with only `time` set (no text conditions) returns Ok immediately + // after sleeping — we test that the function signature compiles correctly + // by verifying BrowserStep::WaitFor deserialises with the timeout_ms field. + use crate::schema::BrowserStep; + // timeout_ms is now optional; verify it deserialises both with and without the field. + let json_with = r#"{"action":"wait_for","time":0.001,"timeout_ms":5000}"#; + let step: BrowserStep = serde_json::from_str(json_with).unwrap(); + assert!( + matches!(step, BrowserStep::WaitFor { time: Some(t), timeout_ms: Some(5000), .. } if t < 1.0) + ); + let json_without = r#"{"action":"wait_for","time":0.001}"#; + let step2: BrowserStep = serde_json::from_str(json_without).unwrap(); + assert!( + matches!(step2, BrowserStep::WaitFor { time: Some(t), timeout_ms: None, .. } if t < 1.0) + ); + } + + #[test] + fn verify_text_visible_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"verify_text_visible","text":"Hello world"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!( + matches!(step, BrowserStep::VerifyTextVisible { text, .. } if text == "Hello world") + ); + } + + #[test] + fn verify_list_visible_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"verify_list_visible","ref":"root","items":["Apple","Banana"]}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!(matches!(step, BrowserStep::VerifyListVisible { items, .. } if items.len() == 2)); + } + + #[test] + fn evaluate_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"evaluate","function":"() => document.title"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!( + matches!(step, BrowserStep::Evaluate { function, .. } if function.contains("document.title")) + ); + } + + #[test] + fn run_code_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"run_code","code":"return 42;"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!(matches!(step, BrowserStep::RunCode { code, .. } if code == "return 42;")); + } + + #[test] + fn cookie_list_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"cookie_list","domain":"example.com"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!( + matches!(step, BrowserStep::CookieList { domain: Some(d), .. } if d == "example.com") + ); + } + + #[test] + fn cookie_set_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"cookie_set","name":"tok","value":"xyz","http_only":true}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!( + matches!(step, BrowserStep::CookieSet { name, http_only: true, .. } if name == "tok") + ); + } + + #[test] + fn local_storage_get_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"local_storage_get","key":"auth_token"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!(matches!(step, BrowserStep::LocalStorageGet { key, .. } if key == "auth_token")); + } + + #[test] + fn session_storage_set_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"session_storage_set","key":"sid","value":"abc"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!( + matches!(step, BrowserStep::SessionStorageSet { key, value, .. } if key == "sid" && value == "abc") + ); + } + + #[test] + fn tabs_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"tabs","operation":"list"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!(matches!(step, BrowserStep::Tabs { operation, .. } if operation == "list")); + } + + #[test] + fn resize_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"resize","width":1280,"height":720}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!(matches!( + step, + BrowserStep::Resize { + width: 1280, + height: 720, + .. + } + )); + } + + #[test] + fn handle_dialog_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"handle_dialog","accept":false,"prompt_text":"no"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!( + matches!(step, BrowserStep::HandleDialog { accept: false, prompt_text: Some(t), .. } if t == "no") + ); + } + + #[test] + fn pdf_save_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"pdf_save","path":"/tmp/out.pdf"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!(matches!(step, BrowserStep::PdfSave { path: Some(p), .. } if p == "/tmp/out.pdf")); + } + + #[test] + fn generate_locator_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"generate_locator","ref":"e7"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!(matches!(step, BrowserStep::GenerateLocator { r#ref, .. } if r#ref == "e7")); + } + + #[test] + fn storage_state_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"storage_state","path":"/tmp/state.json"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!( + matches!(step, BrowserStep::StorageState { path: Some(p), .. } if p == "/tmp/state.json") + ); + } + + #[test] + fn set_storage_state_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"set_storage_state","path":"/tmp/state.json"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!( + matches!(step, BrowserStep::SetStorageState { path, .. } if path == "/tmp/state.json") + ); + } + + #[test] + fn route_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"route","pattern":"**/api/data","status":200,"body":"{}"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!( + matches!(step, BrowserStep::Route { pattern, status: Some(200), .. } if pattern == "**/api/data") + ); + } + + #[test] + fn console_messages_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"console_messages","level":"error"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!( + matches!(step, BrowserStep::ConsoleMessages { level: Some(l), .. } if l == "error") + ); + } + + #[test] + fn network_requests_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"network_requests","include_static":true}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!(matches!( + step, + BrowserStep::NetworkRequests { + include_static: true, + .. + } + )); + } + + #[test] + fn verify_value_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"verify_value","ref":"e1","value":"expected"}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!(matches!(step, BrowserStep::VerifyValue { value, .. } if value == "expected")); + } + + #[test] + fn start_video_step_deserialises() { + use crate::schema::BrowserStep; + let json = r#"{"action":"start_video","width":1280,"height":720}"#; + let step: BrowserStep = serde_json::from_str(json).unwrap(); + assert!(matches!( + step, + BrowserStep::StartVideo { + width: Some(1280), + height: Some(720), + .. + } + )); + } +} diff --git a/crates/earl-protocol-browser/tests/assertions.rs b/crates/earl-protocol-browser/tests/assertions.rs new file mode 100644 index 0000000..3cf2c8f --- /dev/null +++ b/crates/earl-protocol-browser/tests/assertions.rs @@ -0,0 +1,326 @@ +//! Use-case tests: multi-step assertion steps (Group 9). +mod common; +use common::{Response, execute, skip_if_no_chrome, spawn}; +use earl_protocol_browser::PreparedBrowserCommand; +use earl_protocol_browser::schema::BrowserStep; +use std::collections::HashMap; + +/// Test 9.1 — verify_text_visible passes when text is present. +#[tokio::test] +async fn verify_text_visible_passes_when_text_present() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("

Order confirmed

"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::VerifyTextVisible { + text: "Order confirmed".to_string(), + optional: false, + }, + ], + }; + + execute(data).await.expect( + "VerifyTextVisible should succeed when the text 'Order confirmed' is present in the DOM", + ); +} + +/// Test 9.2 — verify_text_visible fails when text is absent. +#[tokio::test] +async fn verify_text_visible_fails_when_text_absent() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("

Nothing here

"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::VerifyTextVisible { + text: "Order confirmed".to_string(), + optional: false, + }, + ], + }; + + let result = execute(data).await; + assert!( + result.is_err(), + "VerifyTextVisible should fail with an error when 'Order confirmed' is not present; got Ok: {:?}", + result.ok() + ); +} + +/// Test 9.3 — verify_element_visible passes when element exists. +#[tokio::test] +async fn verify_element_visible_passes_when_element_exists() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html( + r#""#, + ), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::VerifyElementVisible { + role: Some("button".to_string()), + accessible_name: Some("Submit".to_string()), + optional: false, + }, + ], + }; + + execute(data).await.expect( + "VerifyElementVisible should succeed when a button with aria-label 'Submit' is present", + ); +} + +/// Test 9.4 — verify_element_visible fails when the element is absent (Issue I4). +#[tokio::test] +async fn verify_element_visible_fails_when_element_absent() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("

No ghost here

"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::VerifyElementVisible { + role: Some("button".to_string()), + accessible_name: Some("ghost".to_string()), + optional: false, + }, + ], + }; + + let err = execute(data) + .await + .expect_err("should fail when element is absent"); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("ghost") || msg.contains("not found") || msg.contains("visible"), + "error message should mention 'ghost', 'not found', or 'visible'; got: {err}" + ); +} + +/// Test 9.5 — verify_list_visible passes when all items are present (Issue I5). +#[tokio::test] +async fn verify_list_visible_passes_when_all_items_present() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html( + "
  • Apple
  • Banana
  • Cherry
", + ), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::VerifyListVisible { + r#ref: None, + items: vec![ + "Apple".to_string(), + "Banana".to_string(), + "Cherry".to_string(), + ], + optional: false, + }, + ], + }; + + execute(data) + .await + .expect("VerifyListVisible should succeed when all items are present in the DOM"); +} + +/// Test 9.6 — verify_list_visible fails when an item is missing (Issue I5). +#[tokio::test] +async fn verify_list_visible_fails_when_item_missing() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html( + "
  • Apple
  • Banana
  • Cherry
", + ), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::VerifyListVisible { + r#ref: None, + items: vec![ + "Apple".to_string(), + "Banana".to_string(), + "Durian".to_string(), + ], + optional: false, + }, + ], + }; + + execute(data) + .await + .expect_err("should fail when item 'Durian' is missing from the page"); +} + +/// Test 9.7 — verify_value matches the value attribute of a focused input field (Issue I5). +#[tokio::test] +async fn verify_value_matches_input_field_value() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html(r#""#), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + // Focus the input so that step_verify_value picks it up via document.activeElement. + BrowserStep::Evaluate { + function: "() => { document.getElementById('price').focus(); return true; }" + .to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::VerifyValue { + r#ref: None, + value: "42.00".to_string(), + optional: false, + }, + ], + }; + + let result = execute(data) + .await + .expect("VerifyValue should succeed when the focused input has value '42.00'"); + assert_eq!( + result["ok"], + serde_json::Value::Bool(true), + "VerifyValue result should be {{\"ok\": true}}; got: {result}" + ); +} diff --git a/crates/earl-protocol-browser/tests/common/mod.rs b/crates/earl-protocol-browser/tests/common/mod.rs new file mode 100644 index 0000000..2c742b0 --- /dev/null +++ b/crates/earl-protocol-browser/tests/common/mod.rs @@ -0,0 +1,103 @@ +// tests/common/mod.rs + +#![allow(dead_code, unused_imports)] + +pub mod server; +pub use server::*; + +use std::sync::{Arc, OnceLock}; +use std::time::Duration; +use tokio::sync::{Mutex, OwnedMutexGuard}; + +use earl_core::schema::ResultTemplate; +use earl_core::transport::ResolvedTransport; +use earl_core::{CommandMode, ExecutionContext, ProtocolExecutor, Redactor}; +use earl_protocol_browser::{BrowserExecutor, PreparedBrowserCommand}; +use serde_json::{Map, Value}; + +/// A guard that holds both the process-level mutex guard and the file-based +/// advisory lock. Both are released when this guard is dropped. +pub struct ChromeGuard { + _mutex_guard: OwnedMutexGuard<()>, + _file: std::fs::File, +} + +impl Drop for ChromeGuard { + fn drop(&mut self) { + use fs4::fs_std::FileExt; + // Best-effort unlock; ignore errors during drop. + let _ = self._file.unlock(); + } +} + +/// Returns a `ChromeGuard` that serializes Chrome tests both within a single +/// test binary (via a process-level `Mutex`) and across test binaries (via a +/// file-based advisory lock using `fs4`). +pub async fn chrome_lock() -> ChromeGuard { + // Process-level mutex — one per binary, shared across all tests in this process. + static PROCESS_MUTEX: OnceLock>> = OnceLock::new(); + let mutex = PROCESS_MUTEX + .get_or_init(|| Arc::new(Mutex::new(()))) + .clone(); + let mutex_guard = mutex.lock_owned().await; + + // File-based advisory lock — serializes across separate test binaries. + let lock_path = std::env::temp_dir().join("earl_browser_tests.lock"); + let file = tokio::task::spawn_blocking(move || -> std::io::Result { + use fs4::fs_std::FileExt; + let f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path)?; + f.lock_exclusive()?; + Ok(f) + }) + .await + .expect("spawn_blocking panicked") + .expect("failed to acquire file lock for Chrome tests"); + + ChromeGuard { + _mutex_guard: mutex_guard, + _file: file, + } +} + +pub fn skip_if_no_chrome() -> bool { + if earl_protocol_browser::launcher::find_chrome().is_err() { + eprintln!("skipping — Chrome not found on this host"); + return true; + } + false +} + +pub fn make_context() -> ExecutionContext { + ExecutionContext { + key: "test".to_string(), + mode: CommandMode::Read, + allow_rules: vec![], + transport: ResolvedTransport { + timeout: Duration::from_secs(30), + follow_redirects: true, + max_redirect_hops: 10, + retry_max_attempts: 0, + retry_backoff: Duration::from_millis(100), + retry_on_status: vec![], + compression: false, + tls_min_version: None, + proxy_url: None, + max_response_bytes: 10_000_000, + }, + result_template: ResultTemplate::default(), + args: Map::new(), + redactor: Redactor::new(vec![]), + } +} + +/// Run a `PreparedBrowserCommand` through `BrowserExecutor` and parse the body as JSON. +pub async fn execute(data: PreparedBrowserCommand) -> anyhow::Result { + let mut executor = BrowserExecutor; + let result = executor.execute(&data, &make_context()).await?; + Ok(serde_json::from_slice(&result.body)?) +} diff --git a/crates/earl-protocol-browser/tests/common/server.rs b/crates/earl-protocol-browser/tests/common/server.rs new file mode 100644 index 0000000..fd1ca09 --- /dev/null +++ b/crates/earl-protocol-browser/tests/common/server.rs @@ -0,0 +1,182 @@ +// tests/common/server.rs + +#![allow(dead_code)] + +use std::collections::HashMap; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; + +/// A single route's response. +pub struct Response { + pub status: u16, + pub content_type: &'static str, + pub body: String, + /// Extra headers like "Set-Cookie" or "Location" + pub extra_headers: Vec<(&'static str, String)>, +} + +impl Response { + pub fn ok(content_type: &'static str, body: impl Into) -> Self { + Self { + status: 200, + content_type, + body: body.into(), + extra_headers: vec![], + } + } + + pub fn html(body: impl Into) -> Self { + Self::ok("text/html", body) + } + + pub fn redirect(location: impl Into) -> Self { + Self { + status: 302, + content_type: "text/plain", + body: String::new(), + extra_headers: vec![("Location", location.into())], + } + } + + pub fn with_cookie(mut self, cookie: impl Into) -> Self { + self.extra_headers.push(("Set-Cookie", cookie.into())); + self + } +} + +pub struct TestServer { + pub port: u16, + abort: tokio::task::AbortHandle, +} + +impl Drop for TestServer { + fn drop(&mut self) { + self.abort.abort(); + } +} + +impl TestServer { + pub fn url(&self, path: &str) -> String { + format!("http://127.0.0.1:{}{}", self.port, path) + } +} + +/// Spawn a local HTTP/1.1 server. +/// +/// `routes` maps `"METHOD /path"` (e.g. `"GET /login"`, `"POST /submit"`) to a +/// `Response`. Unknown routes return 404. The server is shut down when the +/// returned `TestServer` is dropped. +pub async fn spawn(routes: HashMap) -> TestServer { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + use std::sync::Arc; + let routes = Arc::new(routes); + + let task = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let routes = Arc::clone(&routes); + tokio::spawn(async move { + handle_connection(stream, routes).await; + }); + } + }); + + TestServer { + port, + abort: task.abort_handle(), + } +} + +async fn handle_connection( + stream: tokio::net::TcpStream, + routes: std::sync::Arc>, +) { + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + + // Read request line. + let mut request_line = String::new(); + if reader.read_line(&mut request_line).await.is_err() { + return; + } + let parts: Vec<&str> = request_line.trim().splitn(3, ' ').collect(); + if parts.len() < 2 { + return; + } + let method = parts[0].to_uppercase(); + let path = parts[1].to_string(); + + // Drain headers. + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).await.is_err() { + return; + } + let line = line.trim(); + if line.is_empty() { + break; + } + if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") { + content_length = rest.trim().parse().unwrap_or(0); + } + } + + // Read body for POST. + let _body = if content_length > 0 { + use tokio::io::AsyncReadExt; + let mut buf = vec![0u8; content_length]; + let _ = reader.read_exact(&mut buf).await; + String::from_utf8_lossy(&buf).to_string() + } else { + String::new() + }; + + let key = format!("{} {}", method, path); + let response = routes + .get(&key) + .or_else(|| routes.get(&format!("ANY {}", path))); + + let (status, status_text, content_type, body, extra_headers) = match response { + Some(r) => ( + r.status, + status_text(r.status), + r.content_type, + r.body.clone(), + &r.extra_headers[..], + ), + None => ( + 404, + "Not Found", + "text/plain", + "Not Found".to_string(), + [].as_slice(), + ), + }; + + let mut resp = format!( + "HTTP/1.1 {status} {status_text}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n", + body.len() + ); + for (k, v) in extra_headers { + resp.push_str(&format!("{k}: {v}\r\n")); + } + resp.push_str("\r\n"); + resp.push_str(&body); + + let _ = writer.write_all(resp.as_bytes()).await; +} + +fn status_text(code: u16) -> &'static str { + match code { + 200 => "OK", + 301 => "Moved Permanently", + 302 => "Found", + 404 => "Not Found", + _ => "Unknown", + } +} diff --git a/crates/earl-protocol-browser/tests/cookies.rs b/crates/earl-protocol-browser/tests/cookies.rs new file mode 100644 index 0000000..1aa578c --- /dev/null +++ b/crates/earl-protocol-browser/tests/cookies.rs @@ -0,0 +1,413 @@ +//! Use-case tests: cookie management (Group 5). +mod common; +use common::{Response, execute, skip_if_no_chrome, spawn}; +use earl_protocol_browser::PreparedBrowserCommand; +use earl_protocol_browser::schema::BrowserStep; +use std::collections::HashMap; + +fn unique_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let count = COUNTER.fetch_add(1, Ordering::Relaxed); + let pid = std::process::id(); + format!("{pid}-{count}") +} + +/// Test 5.1 — Server-set cookies are visible via cookie_list. +/// +/// Serves a page that responds with a Set-Cookie header. After navigating to +/// it, CookieList must return an array containing the expected cookie. +#[tokio::test] +async fn server_set_cookie_visible_in_cookie_list() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /set-cookie".to_string(), + Response::html("cookie set").with_cookie("token=abc123; Path=/"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/set-cookie"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::CookieList { + domain: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + let cookies = result["cookies"] + .as_array() + .expect("result should have a 'cookies' array"); + + assert!( + cookies + .iter() + .any(|c| c["name"] == "token" && c["value"] == "abc123"), + "expected cookie 'token=abc123' in cookie list; got: {result}" + ); +} + +/// Test 5.2 — cookie_set makes cookie visible to the page. +/// +/// After navigating to a page and setting a cookie via CookieSet, an Evaluate +/// step must report that the cookie appears in document.cookie. +#[tokio::test] +async fn cookie_set_visible_to_page() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("hello"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::CookieSet { + name: "theme".to_string(), + value: "dark".to_string(), + domain: Some("127.0.0.1".to_string()), + path: None, + expires: None, + http_only: false, + secure: false, + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.cookie".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + let cookie_str = result["value"] + .as_str() + .expect("evaluate result should have a string 'value'"); + + assert!( + cookie_str.contains("theme=dark"), + "expected 'theme=dark' in document.cookie; got: {cookie_str}" + ); +} + +/// Test 5.3 — cookie_delete removes a cookie. +/// +/// Sets a cookie, deletes it, then lists cookies to verify it is absent. +#[tokio::test] +async fn cookie_delete_removes_cookie() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("hello"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::CookieSet { + name: "temp".to_string(), + value: "yes".to_string(), + domain: Some("127.0.0.1".to_string()), + path: None, + expires: None, + http_only: false, + secure: false, + optional: false, + }, + BrowserStep::CookieDelete { + name: "temp".to_string(), + optional: false, + }, + BrowserStep::CookieList { + domain: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + let cookies = result["cookies"] + .as_array() + .expect("result should have a 'cookies' array"); + + assert!( + !cookies.iter().any(|c| c["name"] == "temp"), + "cookie 'temp' should have been deleted; got: {result}" + ); +} + +/// Test 5.4 — cookie_clear removes all cookies. +/// +/// Sets two cookies, clears all cookies, then verifies the cookie list is empty. +#[tokio::test] +async fn cookie_clear_removes_all_cookies() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("hello"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::CookieSet { + name: "a".to_string(), + value: "1".to_string(), + domain: Some("127.0.0.1".to_string()), + path: None, + expires: None, + http_only: false, + secure: false, + optional: false, + }, + BrowserStep::CookieSet { + name: "b".to_string(), + value: "2".to_string(), + domain: Some("127.0.0.1".to_string()), + path: None, + expires: None, + http_only: false, + secure: false, + optional: false, + }, + BrowserStep::CookieClear { optional: false }, + BrowserStep::CookieList { + domain: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + let cookies = result["cookies"] + .as_array() + .expect("result should have a 'cookies' array"); + + assert!( + cookies.is_empty(), + "cookie list should be empty after cookie_clear; got: {result}" + ); +} + +/// Test 5.5 — storage_state exports and set_storage_state restores cookies across sessions. +/// +/// Session A sets a cookie and writes storage state to a temp file. Session B +/// reads the state file and restores it, then verifies the cookie is present. +#[tokio::test] +async fn storage_state_round_trips_cookies_across_sessions() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("hello"), + ); + let server = spawn(routes).await; + + let id = unique_id(); + // Use a relative path — validate_file_path rejects absolute paths. + let tmp_path = format!("earl-test-state-{id}.json"); + + // Session A: navigate, set cookie, export storage state to file. + let session_a = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::CookieSet { + name: "auth".to_string(), + value: "token123".to_string(), + domain: Some("127.0.0.1".to_string()), + path: None, + expires: None, + http_only: false, + secure: false, + optional: false, + }, + BrowserStep::StorageState { + path: Some(tmp_path.clone()), + optional: false, + }, + ], + }; + + execute(session_a).await.expect("session A should succeed"); + + // Session B: navigate, restore storage state, list cookies. + let session_b = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::SetStorageState { + path: tmp_path.clone(), + optional: false, + }, + BrowserStep::CookieList { + domain: None, + optional: false, + }, + ], + }; + + let result = execute(session_b).await.expect("session B should succeed"); + + std::fs::remove_file(&tmp_path).ok(); + + let cookies = result["cookies"] + .as_array() + .expect("result should have a 'cookies' array"); + + assert!( + cookies + .iter() + .any(|c| c["name"] == "auth" && c["value"] == "token123"), + "expected restored cookie 'auth=token123' in session B; got: {result}" + ); +} + +/// Test 5.6 — cookie_get returns a named cookie. +/// +/// Sets a cookie via CookieSet, then retrieves it by name using CookieGet and +/// verifies the returned value matches. +#[tokio::test] +async fn cookie_get_returns_named_cookie() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("hello"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::CookieSet { + name: "token".to_string(), + value: "abc123".to_string(), + domain: Some("127.0.0.1".to_string()), + path: None, + expires: None, + http_only: false, + secure: false, + optional: false, + }, + BrowserStep::CookieGet { + name: "token".to_string(), + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"].as_str(), + Some("abc123"), + "expected cookie 'token' to have value 'abc123'; got: {result}" + ); +} diff --git a/crates/earl-protocol-browser/tests/dialogs.rs b/crates/earl-protocol-browser/tests/dialogs.rs new file mode 100644 index 0000000..452d4ef --- /dev/null +++ b/crates/earl-protocol-browser/tests/dialogs.rs @@ -0,0 +1,241 @@ +//! Use-case tests: dialog handling (Group 7). +//! +//! Dialogs are triggered via `window.onload` + `setTimeout(..., 100)` so +//! that the Navigate step completes before the dialog fires. HandleDialog +//! subscribes to `Page.javascriptDialogOpening` events and waits up to the +//! global timeout for a dialog to appear. +mod common; +use common::{Response, execute, skip_if_no_chrome, spawn}; +use earl_protocol_browser::PreparedBrowserCommand; +use earl_protocol_browser::schema::BrowserStep; +use std::collections::HashMap; + +/// Test 7.1 — handle_dialog accepts an alert. +/// +/// The page fires `alert('Hello')` 100 ms after load. HandleDialog accepts +/// the pending dialog and the step succeeds with `{"ok": true}`. +#[tokio::test] +async fn handle_dialog_accepts_alert() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html( + r#"

loaded

"#, + ), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::HandleDialog { + accept: true, + prompt_text: None, + optional: false, + }, + ], + }; + + let result = execute(data) + .await + .expect("execute should succeed — dialog should be accepted"); + assert_eq!( + result["ok"].as_bool(), + Some(true), + "HandleDialog result should be {{\"ok\": true}}; got: {result}" + ); +} + +/// Test 7.2 — handle_dialog with prompt_text fills the prompt. +/// +/// The page opens `window.prompt()` 100 ms after load and writes the +/// returned value to `document.title`. HandleDialog accepts with `"my-answer"` +/// and the subsequent Evaluate step reads back the title. +#[tokio::test] +async fn handle_dialog_fills_prompt_text() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html( + r#""#, + ), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::HandleDialog { + accept: true, + prompt_text: Some("my-answer".to_string()), + optional: false, + }, + // Short wait to let the JS callback finish setting document.title + // after the dialog is dismissed. + BrowserStep::WaitFor { + time: Some(0.2), + text: None, + text_gone: None, + timeout_ms: Some(2000), + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.title".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + assert_eq!( + result["value"].as_str(), + Some("my-answer"), + "document.title should equal 'my-answer' after prompt is filled; got: {result}" + ); +} + +/// Test 7.3 — handle_dialog rejects a confirm dialog. +/// +/// The page opens `window.confirm()` 100 ms after load and sets +/// `document.body.textContent` to `"yes"` or `"no"`. Dismissing with +/// `accept: false` must produce `"no"`. +#[tokio::test] +async fn handle_dialog_rejects_confirm() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html( + r#""#, + ), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::HandleDialog { + accept: false, + prompt_text: None, + optional: false, + }, + // Wait for the JS callback to update body.textContent after the + // dialog is dismissed. + BrowserStep::WaitFor { + time: None, + text: Some("no".to_string()), + text_gone: None, + timeout_ms: Some(2000), + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.body.textContent.trim()".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + assert_eq!( + result["value"].as_str(), + Some("no"), + "body text should be 'no' after confirm is rejected; got: {result}" + ); +} + +/// Test 7.4 — HandleDialog times out when no dialog fires (Issue I4). +/// +/// Navigate to a plain HTML page with no dialog, then call HandleDialog +/// immediately. Because no dialog ever fires, the command should time out +/// and return an error mentioning "timeout". +#[tokio::test] +async fn handle_dialog_times_out_when_no_dialog_fires() { + if skip_if_no_chrome() { + return; + } + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("no dialog here"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 2_000, // short timeout so the test completes quickly + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::HandleDialog { + accept: true, + prompt_text: None, + optional: false, + }, + ], + }; + + let err = execute(data) + .await + .expect_err("HandleDialog should time out when no dialog fires"); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("timeout") || msg.contains("timed out") || msg.contains("time"), + "error should mention timeout; got: {msg}" + ); +} diff --git a/crates/earl-protocol-browser/tests/forms.rs b/crates/earl-protocol-browser/tests/forms.rs new file mode 100644 index 0000000..73b1db8 --- /dev/null +++ b/crates/earl-protocol-browser/tests/forms.rs @@ -0,0 +1,503 @@ +//! Use-case tests: form automation — fill, submit, select, check. +//! +//! Each test serves a controlled local HTTP page so assertions are deterministic. +//! Chrome-dependent tests skip gracefully when Chrome is not found. + +mod common; +use common::{execute, skip_if_no_chrome}; + +use earl_protocol_browser::PreparedBrowserCommand; +use earl_protocol_browser::schema::BrowserStep; +use std::collections::HashMap; + +/// Test 3.1 — Fill and submit a form, verify the browser navigated to /submit. +/// +/// Two routes are served: GET /form with an HTML form and GET /submit with a +/// confirmation page. The test fills the inputs, clicks submit, and verifies +/// the snapshot contains "Submitted". +#[tokio::test] +async fn fill_and_submit_form() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let form_html = r#" +
+ + + +
+"#; + + let submit_html = "

Submitted

"; + + let mut routes = HashMap::new(); + routes.insert( + "GET /form".to_string(), + common::server::Response::html(form_html), + ); + routes.insert( + "POST /submit".to_string(), + common::server::Response::html(submit_html), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/form"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Fill { + r#ref: None, + selector: Some("#email".to_string()), + text: "alice@example.com".to_string(), + submit: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Fill { + r#ref: None, + selector: Some("#name".to_string()), + text: "Alice".to_string(), + submit: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Click { + r#ref: None, + selector: Some("button[type=submit]".to_string()), + double_click: false, + timeout_ms: None, + optional: false, + }, + BrowserStep::WaitFor { + time: None, + text: Some("Submitted".to_string()), + text_gone: None, + timeout_ms: Some(10_000), + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.body.innerText".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + let text = result["value"] + .as_str() + .expect("evaluate should return a string"); + assert!( + text.contains("Submitted"), + "body text should contain 'Submitted' (we navigated to /submit); got: {text}" + ); +} + +/// Test 3.2 — Select a dropdown option and verify the change event fires. +/// +/// A page with a ` + + + + +

none

+ +"#; + + let mut routes = HashMap::new(); + routes.insert("GET /".to_string(), common::server::Response::html(body)); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::SelectOption { + r#ref: None, + selector: Some("#color".to_string()), + values: vec!["blue".to_string()], + timeout_ms: None, + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.getElementById('chosen').textContent".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::Value::String("blue".to_string()), + "evaluate should return 'blue' after selecting the option; got: {result}" + ); +} + +/// Test 3.3a — Check a checkbox, then verify it is checked. +/// +/// A page with a bare `` is served. After `check`, +/// `evaluate` must return the boolean `true`. +#[tokio::test] +async fn checkbox_can_be_checked() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + common::server::Response::html( + "", + ), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Check { + r#ref: None, + selector: Some("#tos".to_string()), + timeout_ms: None, + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.getElementById('tos').checked".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::Value::Bool(true), + "evaluate should return true after checking the checkbox; got: {result}" + ); +} + +/// Test 3.3b — Check then uncheck a checkbox, verify it ends up unchecked. +/// +/// The same checkbox page is used. After navigating, checking, and then +/// unchecking, `evaluate` must return `false`. +#[tokio::test] +async fn checked_box_can_be_unchecked() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + common::server::Response::html( + "", + ), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Check { + r#ref: None, + selector: Some("#cb".to_string()), + timeout_ms: None, + optional: false, + }, + BrowserStep::Uncheck { + r#ref: None, + selector: Some("#cb".to_string()), + timeout_ms: None, + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.getElementById('cb').checked".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::Value::Bool(false), + "evaluate should return false after unchecking the checkbox; got: {result}" + ); +} + +/// Test 3.4 — Optional click on absent element does not abort execution. +/// +/// A simple page with no `#cookie-accept-btn` is served. The click step uses +/// `optional: true` with a short timeout. Execution must succeed and the +/// subsequent `evaluate` must return a string (the page title). +#[tokio::test] +async fn optional_click_on_absent_element_continues() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + common::server::Response::html( + "No Cookie Banner

Hello

", + ), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + // This element is absent; the step must be skipped silently. + BrowserStep::Click { + r#ref: None, + selector: Some("#cookie-accept-btn".to_string()), + double_click: false, + timeout_ms: Some(1_000), + optional: true, + }, + BrowserStep::Evaluate { + function: "() => document.title".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data) + .await + .expect("execute should succeed despite the absent element"); + + assert!( + result["value"].is_string(), + "evaluate should return a string (the page title); got: {result}" + ); +} + +/// Test 3.5 — fill with submit=true triggers form submission. +/// +/// Two routes are served: GET / with a form and POST /done with a confirmation +/// page. Setting `submit: Some(true)` on the fill step presses Enter after +/// typing, which submits the form. The final evaluate must contain "Done". +#[tokio::test] +async fn fill_with_submit_true_submits_form() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let form_html = r#" +
+ + +
+"#; + + let done_html = "

Done

"; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + common::server::Response::html(form_html), + ); + routes.insert( + "POST /done".to_string(), + common::server::Response::html(done_html), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Fill { + r#ref: None, + selector: Some("#q".to_string()), + text: "hello".to_string(), + submit: Some(true), + timeout_ms: None, + optional: false, + }, + // Poll until "Done" appears rather than sleeping a fixed duration. + BrowserStep::WaitFor { + text: Some("Done".to_string()), + text_gone: None, + time: None, + timeout_ms: Some(5_000), + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.body.innerText".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + let text = result["value"] + .as_str() + .expect("evaluate should return a string"); + assert!( + text.contains("Done"), + "body text should contain 'Done' (navigated to /done via form submit with Enter); got: {text}" + ); +} + +/// Test 3.6 — FillForm fills multiple fields at once and values are readable. +/// +/// A page with two text inputs (id="name" and id="email") is served. +/// `FillForm` sets both fields in a single step. An `evaluate` then reads +/// both values back; the result must be `"Alice|alice@example.com"`. +#[tokio::test] +async fn fill_form_fills_multiple_fields_at_once() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let page_html = r#" +
+ + + +
+"#; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + common::server::Response::html(page_html), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::FillForm { + fields: vec![ + serde_json::json!({"selector": "#name", "value": "Alice"}), + serde_json::json!({"selector": "#email", "value": "alice@example.com"}), + ], + timeout_ms: None, + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.getElementById('name').value + '|' + document.getElementById('email').value".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::Value::String("Alice|alice@example.com".to_string()), + "evaluate should return 'Alice|alice@example.com' after FillForm; got: {result}" + ); +} diff --git a/crates/earl-protocol-browser/tests/integration.rs b/crates/earl-protocol-browser/tests/integration.rs new file mode 100644 index 0000000..1fcde0e --- /dev/null +++ b/crates/earl-protocol-browser/tests/integration.rs @@ -0,0 +1,167 @@ +//! Integration tests for the browser protocol. +//! +//! Chrome-dependent tests skip gracefully when Chrome is not found on the host. +//! The URL-scheme validation test does not require Chrome. +//! +//! Chrome tests are serialized via a process-wide `Mutex` to avoid the +//! Chromium singleton-lock error that occurs when two instances try to use the +//! same profile directory at the same time. + +mod common; +use common::*; + +use earl_core::{ProtocolExecutor, RawExecutionResult}; +use earl_protocol_browser::schema::BrowserStep; +use earl_protocol_browser::{BrowserExecutor, PreparedBrowserCommand, steps::validate_url_scheme}; + +// ── scheme-validation tests (no Chrome required) ────────────────────────────── + +#[test] +fn allowed_schemes_are_accepted() { + assert!(validate_url_scheme("http://example.com").is_ok()); + assert!(validate_url_scheme("https://example.com").is_ok()); +} + +#[test] +fn file_scheme_is_rejected() { + let err = validate_url_scheme("file:///etc/passwd").unwrap_err(); + assert!( + err.to_string().contains("file"), + "error should mention the disallowed scheme; got: {err}" + ); +} + +#[test] +fn javascript_scheme_is_rejected() { + let err = validate_url_scheme("javascript:alert(1)").unwrap_err(); + assert!( + err.to_string().contains("javascript"), + "error should mention the disallowed scheme; got: {err}" + ); +} + +#[test] +fn data_scheme_is_rejected() { + let err = validate_url_scheme("data:text/html,

hi

").unwrap_err(); + assert!( + err.to_string().contains("data"), + "error should mention the disallowed scheme; got: {err}" + ); +} + +#[test] +fn blob_scheme_is_rejected() { + let err = validate_url_scheme("blob:https://example.com/uuid").unwrap_err(); + assert!( + err.to_string().contains("blob"), + "error should mention the disallowed scheme; got: {err}" + ); +} + +// ── Chrome-dependent tests ──────────────────────────────────────────────────── + +/// Navigate to `https://example.com`, take a snapshot, and verify the raw +/// result body contains a JSON object with a `"text"` field. +#[tokio::test] +#[ignore = "requires external network"] +async fn navigate_and_snapshot() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: "https://example.com".into(), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Snapshot { + timeout_ms: None, + optional: false, + }, + ], + }; + + let ctx = make_context(); + let mut executor = BrowserExecutor; + let result: RawExecutionResult = executor + .execute(&data, &ctx) + .await + .expect("execute should succeed"); + + assert_eq!(result.content_type.as_deref(), Some("application/json")); + + let json: serde_json::Value = + serde_json::from_slice(&result.body).expect("body should be valid JSON"); + + // The last step was Snapshot; its result should be an object containing + // at least a "text" key with the page's accessibility tree text. + assert!( + json.get("text").is_some(), + "snapshot result should have a 'text' field; got: {json}" + ); +} + +/// Navigate to `https://example.com`, attempt to click a non-existent element +/// with `optional: true` (so the step is silently skipped), then take a +/// snapshot. The overall execution must succeed. +#[tokio::test] +#[ignore = "requires external network"] +async fn optional_step_continues_on_failure() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: "https://example.com".into(), + expected_status: None, + timeout_ms: None, + optional: false, + }, + // This element does not exist; because optional = true the step + // engine should log a warning and continue rather than abort. + BrowserStep::Click { + r#ref: None, + selector: Some("#this-element-does-not-exist-abc123".into()), + double_click: false, + timeout_ms: Some(2_000), + optional: true, + }, + BrowserStep::Snapshot { + timeout_ms: None, + optional: false, + }, + ], + }; + + let ctx = make_context(); + let mut executor = BrowserExecutor; + let result = executor + .execute(&data, &ctx) + .await + .expect("execute should succeed even though the click step failed"); + + let json: serde_json::Value = + serde_json::from_slice(&result.body).expect("body should be valid JSON"); + + assert!( + json.get("text").is_some(), + "final snapshot result should have a 'text' field; got: {json}" + ); +} diff --git a/crates/earl-protocol-browser/tests/javascript.rs b/crates/earl-protocol-browser/tests/javascript.rs new file mode 100644 index 0000000..3d8a73c --- /dev/null +++ b/crates/earl-protocol-browser/tests/javascript.rs @@ -0,0 +1,313 @@ +//! Use-case tests: JavaScript execution — evaluate and run_code steps. +//! +//! Each test serves a controlled local HTTP page so assertions are deterministic. +//! Chrome-dependent tests skip gracefully when Chrome is not found. + +mod common; +use common::{execute, skip_if_no_chrome}; + +use earl_protocol_browser::PreparedBrowserCommand; +use earl_protocol_browser::schema::BrowserStep; +use std::collections::HashMap; + +/// Test 10.1 — evaluate returns a JS expression result. +/// +/// A simple arithmetic expression `1 + 1` must evaluate to the JSON number `2`. +#[tokio::test] +async fn evaluate_returns_expression_result() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + common::server::Response::html(""), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Evaluate { + function: "() => 1 + 1".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::Value::Number(serde_json::Number::from(2)), + "evaluate should return 2 as a JSON number; got: {result}" + ); +} + +/// Test 10.2 — evaluate reads the DOM title. +/// +/// A page is served with a known `` element. The `evaluate` step must +/// return the page title as a JSON string. +#[tokio::test] +async fn evaluate_reads_dom_title() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + common::server::Response::html( + "<html><head><title>My Test Title", + ), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.title".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::Value::String("My Test Title".to_string()), + "evaluate should return the page title; got: {result}" + ); +} + +/// Test 10.3 — run_code executes multi-statement code. +/// +/// A multi-statement code block computes `40 + 2` and returns `42`. The +/// result must be the JSON number `42`. +#[tokio::test] +async fn run_code_executes_multi_statement() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + common::server::Response::html(""), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::RunCode { + code: "const x = 40; const y = 2; return x + y;".to_string(), + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::Value::Number(serde_json::Number::from(42)), + "run_code should return 42 as a JSON number; got: {result}" + ); +} + +/// Test 10.4 — run_code mutations are visible to a subsequent evaluate. +/// +/// `run_code` sets `document.title` to a known string. A subsequent +/// `evaluate` step must observe the mutation. +#[tokio::test] +async fn run_code_mutation_visible_to_evaluate() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + common::server::Response::html( + "original", + ), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::RunCode { + code: "document.title = 'injected'; return document.title;".to_string(), + timeout_ms: None, + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.title".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::Value::String("injected".to_string()), + "evaluate should see the title mutated by run_code; got: {result}" + ); +} + +/// Test 10.5 — evaluate propagates a JavaScript exception (Issue I4). +/// +/// A function that throws must cause `execute` to return an error whose +/// message contains the thrown text. +#[tokio::test] +async fn evaluate_propagates_javascript_exception() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + common::server::Response::html(""), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Evaluate { + function: "() => { throw new Error('boom'); }".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let err = execute(data) + .await + .expect_err("evaluate should fail when JS throws"); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("boom") || msg.contains("error"), + "error message should mention 'boom' or 'error'; got: {err}" + ); +} + +/// Test 10.6 — run_code propagates a JavaScript exception (Issue I4). +/// +/// A code block that throws must cause `execute` to return an error whose +/// message contains the thrown text. +#[tokio::test] +async fn run_code_propagates_javascript_exception() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + common::server::Response::html(""), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::RunCode { + code: "throw new Error('kaboom');".to_string(), + timeout_ms: None, + optional: false, + }, + ], + }; + + let err = execute(data) + .await + .expect_err("run_code should fail when JS throws"); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("kaboom") || msg.contains("error"), + "error message should mention 'kaboom' or 'error'; got: {err}" + ); +} diff --git a/crates/earl-protocol-browser/tests/keyboard_mouse.rs b/crates/earl-protocol-browser/tests/keyboard_mouse.rs new file mode 100644 index 0000000..e9e458c --- /dev/null +++ b/crates/earl-protocol-browser/tests/keyboard_mouse.rs @@ -0,0 +1,125 @@ +//! Use-case tests: keyboard and mouse coordinate interaction. +//! +//! Each test serves a controlled local HTTP page so assertions are deterministic. +//! Chrome-dependent tests skip gracefully when Chrome is not found. + +mod common; +use common::{execute, skip_if_no_chrome}; + +use earl_protocol_browser::PreparedBrowserCommand; +use earl_protocol_browser::schema::BrowserStep; +use std::collections::HashMap; + +/// Test 11.1 — press_key fires keyboard events on the page. +/// +/// A page listens for `keydown` events and writes the pressed key name to a +/// `
`. After pressing "Enter", `evaluate` must return `"Enter"`. +#[tokio::test] +async fn press_key_fires_keyboard_events() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let body = r#" +
none
+ +"#; + + let mut routes = HashMap::new(); + routes.insert("GET /".to_string(), common::server::Response::html(body)); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::PressKey { + key: "Enter".to_string(), + timeout_ms: None, + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.getElementById('out').textContent".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::Value::String("Enter".to_string()), + "evaluate should return 'Enter' after pressing the Enter key; got: {result}" + ); +} + +/// Test 11.2 — mouse_wheel triggers scroll on a tall page. +/// +/// A page taller than the viewport is served. After a downward wheel event, +/// `evaluate` must return `true` for `window.scrollY > 0`. +#[tokio::test] +async fn mouse_wheel_scrolls_tall_page() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let body = r#"
Tall content
"#; + + let mut routes = HashMap::new(); + routes.insert("GET /".to_string(), common::server::Response::html(body)); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::MouseWheel { + delta_x: 0.0, + delta_y: 500.0, + optional: false, + }, + // MouseWheel dispatches synchronously; evaluate scroll position directly. + BrowserStep::Evaluate { + function: "() => window.scrollY > 0".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::Value::Bool(true), + "evaluate should return true (scrollY > 0) after mouse wheel; got: {result}" + ); +} diff --git a/crates/earl-protocol-browser/tests/pdf.rs b/crates/earl-protocol-browser/tests/pdf.rs new file mode 100644 index 0000000..ac31e15 --- /dev/null +++ b/crates/earl-protocol-browser/tests/pdf.rs @@ -0,0 +1,158 @@ +//! Use-case tests: PDF save. +mod common; +use common::{Response, execute, skip_if_no_chrome, spawn}; +use earl_protocol_browser::PreparedBrowserCommand; +use earl_protocol_browser::schema::BrowserStep; +use std::collections::HashMap; + +fn unique_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let count = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("{}-{}", std::process::id(), count) +} + +/// Test 8.1 — pdf_save writes a valid PDF to disk. +/// +/// Serves an HTML invoice page, requests a PDF at an explicit temp path, and +/// verifies that the file exists and starts with the PDF magic bytes `%PDF`. +#[tokio::test] +async fn pdf_save_writes_valid_pdf_to_disk() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("

Invoice #001

Total: $42.00

"), + ); + let server = spawn(routes).await; + + let id = unique_id(); + // Use a relative path — validate_file_path rejects absolute paths. + let path_str = format!("earl-test-invoice-{id}.pdf"); + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::PdfSave { + path: Some(path_str.clone()), + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["path"].as_str(), + Some(path_str.as_str()), + "result 'path' field should match the requested path; got: {}", + result["path"] + ); + assert!( + std::path::Path::new(&path_str).exists(), + "PDF file should exist on disk at {path_str}" + ); + + // Verify the PDF magic bytes: %PDF = [0x25, 0x50, 0x44, 0x46] + let bytes = std::fs::read(&path_str).expect("should be able to read the PDF file"); + assert!( + bytes.len() >= 4, + "PDF file should contain at least 4 bytes; got {} bytes", + bytes.len() + ); + assert_eq!( + &bytes[..4], + &[0x25, 0x50, 0x44, 0x46], + "expected PDF magic bytes (%PDF); got: {:?}", + &bytes[..4] + ); + + std::fs::remove_file(&path_str).ok(); +} + +/// Test 8.2 — pdf_save with no path returns base64 data without writing a file. +/// +/// Omits the `path` field. The executor must return `{"data": "", +/// "size": N}` without touching the file system. The base64 data must decode +/// to valid PDF bytes (magic header `%PDF`). +#[tokio::test] +async fn pdf_save_no_path_returns_base64_data() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("

base64 PDF test

"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::PdfSave { + path: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + // No path given — result must have `data`, not `path`. + assert!( + result["path"].is_null(), + "result should NOT have a 'path' field when no path is given; got: {result}" + ); + + let data_b64 = result["data"] + .as_str() + .expect("result should have a non-null 'data' field"); + assert!(!data_b64.is_empty(), "base64 data should be non-empty"); + + let size = result["size"] + .as_u64() + .expect("result should have a numeric 'size' field"); + assert!(size > 0, "size should be greater than 0"); + + // Decode and verify PDF magic bytes: %PDF = [0x25, 0x50, 0x44, 0x46] + let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data_b64) + .expect("data should be valid base64"); + assert!( + bytes.len() >= 4, + "decoded PDF should have at least 4 bytes; got {}", + bytes.len() + ); + assert_eq!( + &bytes[..4], + &[0x25, 0x50, 0x44, 0x46], + "expected PDF magic bytes (%PDF); got: {:?}", + &bytes[..4] + ); +} diff --git a/crates/earl-protocol-browser/tests/scraping.rs b/crates/earl-protocol-browser/tests/scraping.rs new file mode 100644 index 0000000..2130dda --- /dev/null +++ b/crates/earl-protocol-browser/tests/scraping.rs @@ -0,0 +1,399 @@ +//! Use-case tests: web scraping — extracting content from pages. +//! +//! Each test serves a controlled local HTTP page so assertions are deterministic. +//! Chrome-dependent tests skip gracefully when Chrome is not found. + +mod common; +use common::{execute, skip_if_no_chrome}; + +use earl_protocol_browser::PreparedBrowserCommand; +use earl_protocol_browser::schema::BrowserStep; +use std::collections::HashMap; + +/// Test 1.1 — Extract static text via evaluate. +/// +/// Serve a page with a static `

` heading and use `evaluate` to read its +/// text content. +#[tokio::test] +async fn extract_static_text_via_evaluate() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + common::server::Response::html("

Product: Acme Widget

"), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.querySelector('h1').textContent".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::Value::String("Product: Acme Widget".to_string()), + "evaluate should return the h1 text content; got: {result}" + ); +} + +/// Test 1.2 — Extract dynamically-rendered text using wait_for. +/// +/// The page uses `setTimeout` to inject a `

` element after 300ms. The +/// `wait_for` step must observe the text before `evaluate` reads it. +#[tokio::test] +async fn extract_dynamic_text_with_wait_for() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let body = r#" + +"#; + + let mut routes = HashMap::new(); + routes.insert("GET /".to_string(), common::server::Response::html(body)); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::WaitFor { + time: None, + text: Some("Data loaded".to_string()), + text_gone: None, + timeout_ms: Some(5_000), + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.getElementById('loaded').textContent".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::Value::String("Data loaded".to_string()), + "evaluate should return the dynamically-injected text; got: {result}" + ); +} + +/// Test 1.3 — wait_for times out when content never appears. +/// +/// A blank page is served and `wait_for` looks for text that is never added. +/// The execution must return an error. +#[tokio::test] +async fn wait_for_times_out_when_content_absent() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + common::server::Response::html(""), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::WaitFor { + time: None, + text: Some("content that will never appear".to_string()), + text_gone: None, + timeout_ms: Some(500), + optional: false, + }, + ], + }; + + let err = execute(data) + .await + .expect_err("execute should fail with a timeout error"); + let msg = err.to_string(); + assert!( + msg.to_lowercase().contains("wait_for") + || msg.to_lowercase().contains("timed out") + || msg.to_lowercase().contains("timeout"), + "error should mention a timeout or wait_for; got: {msg}" + ); +} + +/// Test 1.4 — Navigate to a second page; the last snapshot reflects the second page. +/// +/// Two routes are served. After navigating to each in sequence, a `Snapshot` +/// is taken and its `"text"` field must contain content from the second page. +#[tokio::test] +async fn multi_navigate_snapshot_reflects_last_page() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /page1".to_string(), + common::server::Response::html( + "Page One

Page One content

", + ), + ); + routes.insert( + "GET /page2".to_string(), + common::server::Response::html( + "Page Two

Page Two content

", + ), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/page1"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Navigate { + url: server.url("/page2"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Snapshot { + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + let text = result["text"] + .as_str() + .expect("snapshot result should have a 'text' string field"); + assert!( + text.contains("Page Two"), + "snapshot text should contain 'Page Two' (the second page); got: {text}" + ); +} + +/// Test S4 — wait_for with text_gone waits until the text disappears and new +/// text appears. +/// +/// Serves a page that initially shows "Loading..." and after 200 ms replaces +/// its content with "Done". The `WaitFor` step uses both `text_gone` and +/// `text` together so it resolves only after the transition completes. +#[tokio::test] +async fn wait_for_text_gone_waits_until_text_disappears() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let body = r#" +
Loading...
+ +"#; + + let mut routes = HashMap::new(); + routes.insert("GET /".to_string(), common::server::Response::html(body)); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + // Wait for "Loading..." to disappear AND "Done" to appear. + BrowserStep::WaitFor { + time: None, + text: Some("Done".to_string()), + text_gone: Some("Loading...".to_string()), + timeout_ms: Some(5_000), + optional: false, + }, + BrowserStep::Evaluate { + function: "() => document.body.innerText".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + let text = result["value"] + .as_str() + .expect("evaluate should return a string value"); + assert!( + text.contains("Done"), + "page text should contain 'Done' after transition; got: {text}" + ); + assert!( + !text.contains("Loading"), + "page text should no longer contain 'Loading' after transition; got: {text}" + ); +} + +/// Test S3a — Navigate with `expected_status` succeeds when the server returns +/// that exact status code. +/// +/// Registers a `/missing` route that returns HTTP 404 and navigates with +/// `expected_status: Some(404)`. The command must succeed because the status +/// matches. +#[tokio::test] +async fn navigate_expected_status_succeeds_on_match() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /missing".to_string(), + common::server::Response { + status: 404, + content_type: "text/plain", + body: "gone".to_string(), + extra_headers: vec![], + }, + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![BrowserStep::Navigate { + url: server.url("/missing"), + expected_status: Some(404), + timeout_ms: None, + optional: false, + }], + }; + + execute(data) + .await + .expect("execute should succeed when expected_status matches the actual status"); +} + +/// Test S3b — Navigate with `expected_status` fails when the server returns a +/// different status code. +/// +/// Navigates to a page that returns HTTP 200 but specifies +/// `expected_status: Some(404)`. The command must return an error and the +/// error message must mention the status code. +#[tokio::test] +async fn navigate_expected_status_mismatch_fails() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + common::server::Response::html("OK"), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![BrowserStep::Navigate { + url: server.url("/"), + expected_status: Some(404), + timeout_ms: None, + optional: false, + }], + }; + + let err = execute(data) + .await + .expect_err("should fail when status doesn't match expected"); + let msg = err.to_string(); + assert!( + msg.contains("404") || msg.contains("200") || msg.to_lowercase().contains("status"), + "error message should mention status code; got: {msg}" + ); +} diff --git a/crates/earl-protocol-browser/tests/screenshot.rs b/crates/earl-protocol-browser/tests/screenshot.rs new file mode 100644 index 0000000..4dd1bad --- /dev/null +++ b/crates/earl-protocol-browser/tests/screenshot.rs @@ -0,0 +1,236 @@ +//! Use-case tests: screenshot capture. +mod common; +use common::{Response, execute, skip_if_no_chrome, spawn}; +use earl_protocol_browser::PreparedBrowserCommand; +use earl_protocol_browser::schema::BrowserStep; +use std::collections::HashMap; + +fn unique_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let count = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("{}-{}", std::process::id(), count) +} + +/// Test 2.1 — Screenshot produces a valid PNG. +/// +/// Serves a simple HTML page, navigates to it, takes a viewport screenshot, +/// and verifies the returned base64 data decodes to a valid PNG image. +#[tokio::test] +async fn screenshot_produces_valid_png() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("

Hello

"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Screenshot { + path: None, + r#type: None, + full_page: false, + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + // No path given — result contains only `data`, not `path`. + assert!( + result["path"].is_null(), + "result should NOT have a 'path' field when no path is given; got: {result}" + ); + assert!( + result["data"].is_string(), + "result should have a 'data' string field; got: {result}" + ); + + let data_b64 = result["data"].as_str().unwrap(); + let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data_b64) + .expect("data should be valid base64"); + + assert_eq!( + &bytes[..4], + &[0x89, 0x50, 0x4E, 0x47], + "expected PNG magic bytes (\\x89PNG); got: {:?}", + &bytes[..4] + ); +} + +/// Test 2.2 — Full-page screenshot produces more data than a viewport screenshot. +/// +/// Serves an HTML page containing a very tall element. The full-page capture +/// must encode more pixels and therefore produce a longer base64 string than +/// the viewport-only capture. +#[tokio::test] +async fn full_page_screenshot_larger_than_viewport() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html( + r#"
tall
"#, + ), + ); + let server = spawn(routes).await; + + // Command A: viewport screenshot (full_page = false). + let data_viewport = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Screenshot { + path: None, + r#type: None, + full_page: false, + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result_viewport = execute(data_viewport) + .await + .expect("viewport screenshot should succeed"); + let data_b64_viewport = result_viewport["data"] + .as_str() + .expect("viewport result should have 'data' field") + .to_string(); + + // Command B: full-page screenshot (full_page = true). + let data_full = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Screenshot { + path: None, + r#type: None, + full_page: true, + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result_full = execute(data_full) + .await + .expect("full-page screenshot should succeed"); + let data_b64_full_page = result_full["data"] + .as_str() + .expect("full-page result should have 'data' field") + .to_string(); + + assert!( + data_b64_full_page.len() > data_b64_viewport.len(), + "full-page screenshot ({} chars) should be larger than viewport screenshot ({} chars)", + data_b64_full_page.len(), + data_b64_viewport.len() + ); +} + +/// Test 2.3 — Screenshot to a specified path writes the file to disk. +/// +/// Passes an explicit temp-file path to the screenshot step and verifies both +/// that the returned `path` field matches the requested path and that the file +/// exists on disk after execution. +#[tokio::test] +async fn screenshot_to_specified_path_writes_file() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("

screenshot path test

"), + ); + let server = spawn(routes).await; + + let id = unique_id(); + // Use a relative path — validate_file_path rejects absolute paths. + let path_str = format!("earl-test-screenshot-{id}.png"); + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Screenshot { + path: Some(path_str.clone()), + r#type: None, + full_page: false, + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["path"].as_str(), + Some(path_str.as_str()), + "result 'path' field should match the requested path; got: {}", + result["path"] + ); + assert!( + std::path::Path::new(&path_str).exists(), + "screenshot file should exist on disk at {path_str}" + ); + + std::fs::remove_file(&path_str).ok(); +} diff --git a/crates/earl-protocol-browser/tests/session_mode.rs b/crates/earl-protocol-browser/tests/session_mode.rs new file mode 100644 index 0000000..fda7405 --- /dev/null +++ b/crates/earl-protocol-browser/tests/session_mode.rs @@ -0,0 +1,298 @@ +//! Use-case tests: session persistence across multiple earl invocations. +//! +//! Tests the key AI-browsing pattern: each "call" is a separate BrowserExecutor +//! invocation sharing a session_id. State (page URL, cookies, localStorage) +//! persists across calls. +mod common; +use common::{execute, skip_if_no_chrome}; +use earl_protocol_browser::PreparedBrowserCommand; +use earl_protocol_browser::schema::BrowserStep; +use std::collections::HashMap; + +/// Return a string that is unique within this process across concurrent calls. +/// +/// Combines the process ID with a monotonically increasing atomic counter so +/// that two concurrent tests never generate the same session ID, even when they +/// happen to run within the same millisecond. +fn unique_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let count = COUNTER.fetch_add(1, Ordering::Relaxed); + let pid = std::process::id(); + format!("{pid}-{count}") +} + +/// Test 4.1a — Call 1 navigates to /page1 and the snapshot contains "Page One". +#[tokio::test] +async fn session_call_1_succeeds() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let session_id = format!("test-persist-{}", unique_id()); + + let mut routes = HashMap::new(); + routes.insert( + "GET /page1".to_string(), + common::server::Response::html( + "Page One

Page One is here

", + ), + ); + let server = common::server::spawn(routes).await; + + let call1 = PreparedBrowserCommand { + session_id: Some(session_id.clone()), + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/page1"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Snapshot { + timeout_ms: None, + optional: false, + }, + ], + }; + + let result1 = execute(call1).await.expect("call 1 should succeed"); + let text1 = result1["text"] + .as_str() + .expect("call 1 snapshot should have 'text'"); + assert!( + text1.contains("Page One"), + "call 1 snapshot should contain 'Page One'; got: {text1}" + ); + + // Cleanup: session files use unique IDs so stale files are harmless, but + // clean up eagerly to avoid accumulation on long-running CI machines. + use earl_protocol_browser::session::{lock_file_path, session_file_path}; + let _ = std::fs::remove_file(session_file_path(&session_id).unwrap()); + let _ = std::fs::remove_file(lock_file_path(&session_id).unwrap()); +} + +/// Test 4.1b — Call 1 then call 2 both succeed with the same session_id, +/// and both snapshots contain "Page One". +/// +/// Verifies that the session management infrastructure (lock acquisition, +/// session file read/write) works correctly across repeated invocations with +/// the same session_id. +#[tokio::test] +async fn session_call_2_reuses_same_session() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let session_id = format!("test-persist-{}", unique_id()); + + let mut routes = HashMap::new(); + routes.insert( + "GET /page1".to_string(), + common::server::Response::html( + "Page One

Page One is here

", + ), + ); + let server = common::server::spawn(routes).await; + + // Call 1: navigate to /page1 and take a snapshot. + let call1 = PreparedBrowserCommand { + session_id: Some(session_id.clone()), + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/page1"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Snapshot { + timeout_ms: None, + optional: false, + }, + ], + }; + + let result1 = execute(call1).await.expect("call 1 should succeed"); + let text1 = result1["text"] + .as_str() + .expect("call 1 snapshot should have 'text'"); + assert!( + text1.contains("Page One"), + "call 1 snapshot should contain 'Page One'; got: {text1}" + ); + + // Call 2: navigate to /page1 again with the same session_id. + // The session management infrastructure (lock, session file) must handle + // this correctly — even if the underlying Chrome instance is recycled. + let call2 = PreparedBrowserCommand { + session_id: Some(session_id.clone()), + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/page1"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::Snapshot { + timeout_ms: None, + optional: false, + }, + ], + }; + + let result2 = execute(call2).await.expect("call 2 should succeed"); + let text2 = result2["text"] + .as_str() + .expect("call 2 snapshot should have 'text'"); + assert!( + text2.contains("Page One"), + "call 2 snapshot should contain 'Page One'; got: {text2}" + ); + + // Cleanup. + use earl_protocol_browser::session::{lock_file_path, session_file_path}; + let _ = std::fs::remove_file(session_file_path(&session_id).unwrap()); + let _ = std::fs::remove_file(lock_file_path(&session_id).unwrap()); +} + +/// Test 4.2 — Stale session (bad websocket URL) falls back to fresh Chrome. +/// +/// A `SessionFile` is written with a dead websocket URL. The executor must +/// detect the stale connection, launch a fresh Chrome, and complete the steps +/// successfully. +/// +/// Note: internal types (`SessionFile`, `session_file_path`, etc.) are used +/// here deliberately to set up the stale-session precondition. There is no +/// public API for injecting a fake session file, so white-box setup is the +/// only viable approach for this test. +#[tokio::test] +async fn stale_session_falls_back_to_fresh_chrome() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + use chrono::Utc; + use earl_protocol_browser::session::{ + SessionFile, ensure_sessions_dir, lock_file_path, session_file_path, sessions_dir, + }; + + let session_id = format!("test-stale-{}", unique_id()); + let dir = sessions_dir().unwrap(); + ensure_sessions_dir(&dir).unwrap(); + + // Write a stale session file with a dead websocket URL. + let sf = SessionFile { + pid: 0, + websocket_url: "ws://127.0.0.1:1/devtools/browser/fake".to_string(), + target_id: "fake".to_string(), + started_at: Utc::now(), + last_used_at: Utc::now(), + interrupted: false, + }; + sf.save_to(&session_file_path(&session_id).unwrap()) + .unwrap(); + + let mut routes = HashMap::new(); + routes.insert( + "GET /stale-check".to_string(), + common::server::Response::html( + "Fresh Chrome

Fresh Chrome loaded

", + ), + ); + let server = common::server::spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: Some(session_id.clone()), + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/stale-check"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + // Evaluate document.title which is populated immediately after navigation. + BrowserStep::Evaluate { + function: "() => document.title".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + // Execution must succeed — fresh Chrome was launched after detecting stale session. + let result = execute(data) + .await + .expect("stale session should fall back to fresh Chrome"); + let title = result["value"] + .as_str() + .expect("evaluate should return the page title as 'value'"); + assert_eq!( + title, "Fresh Chrome", + "title should be 'Fresh Chrome' confirming navigation succeeded; got: {title}" + ); + + // Cleanup. + let _ = std::fs::remove_file(session_file_path(&session_id).unwrap()); + let _ = std::fs::remove_file(lock_file_path(&session_id).unwrap()); +} + +/// Test 4.3 — Concurrent lock on same session_id returns SessionLocked error. +/// +/// The lock is acquired manually before calling `execute()`. The executor must +/// return an error immediately without trying to launch Chrome. +/// +/// This test does NOT require Chrome. +#[tokio::test] +async fn concurrent_lock_returns_session_locked_error() { + use earl_protocol_browser::session::{acquire_session_lock, lock_file_path}; + + let session_id = format!("test-lock-{}", unique_id()); + + // Acquire the lock — holds it for the duration of this test. + let _lock = acquire_session_lock(&session_id).await.unwrap(); + + let data = PreparedBrowserCommand { + session_id: Some(session_id.clone()), + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![BrowserStep::Snapshot { + timeout_ms: None, + optional: false, + }], + }; + + let err = execute(data) + .await + .expect_err("execute should fail with SessionLocked"); + let msg = err.to_string(); + assert!( + msg.to_lowercase().contains("locked") || msg.contains("SessionLocked"), + "error should mention 'locked' or 'SessionLocked'; got: {msg}" + ); + + // Drop the lock before cleanup. + drop(_lock); + + // Cleanup: delete lock file. + let _ = std::fs::remove_file(lock_file_path(&session_id).unwrap()); +} diff --git a/crates/earl-protocol-browser/tests/storage.rs b/crates/earl-protocol-browser/tests/storage.rs new file mode 100644 index 0000000..3912063 --- /dev/null +++ b/crates/earl-protocol-browser/tests/storage.rs @@ -0,0 +1,410 @@ +//! Use-case tests: localStorage / sessionStorage (Group 6). +mod common; +use common::{Response, execute, skip_if_no_chrome, spawn}; +use earl_protocol_browser::PreparedBrowserCommand; +use earl_protocol_browser::schema::BrowserStep; +use std::collections::HashMap; + +/// Test 6.1 — local_storage_set and local_storage_get round-trip. +/// +/// Sets a key in localStorage and then reads it back, verifying the value +/// matches exactly what was written. +#[tokio::test] +async fn local_storage_set_and_get_round_trip() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("hello"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::LocalStorageSet { + key: "theme".to_string(), + value: "dark".to_string(), + optional: false, + }, + BrowserStep::LocalStorageGet { + key: "theme".to_string(), + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"].as_str(), + Some("dark"), + "expected localStorage['theme'] == 'dark'; got: {result}" + ); +} + +/// Test 6.2 — local_storage_delete removes the key. +/// +/// Sets a key, deletes it, then evaluates localStorage.getItem() via JS to +/// confirm the value is null (the key no longer exists). +#[tokio::test] +async fn local_storage_delete_removes_key() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("hello"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::LocalStorageSet { + key: "x".to_string(), + value: "1".to_string(), + optional: false, + }, + BrowserStep::LocalStorageDelete { + key: "x".to_string(), + optional: false, + }, + // Use Evaluate to check the key is gone; LocalStorageGet on a missing + // key errors, and Evaluate on null also fails. Instead we check + // localStorage.length — after setting one key and deleting it the + // storage must be empty. + BrowserStep::Evaluate { + function: "() => localStorage.length".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::json!(0), + "expected localStorage.length == 0 after delete; got: {result}" + ); +} + +/// Test 6.3 — local_storage_clear wipes all keys. +/// +/// Sets two keys, clears localStorage, then evaluates localStorage.length to +/// confirm no entries remain. +#[tokio::test] +async fn local_storage_clear_wipes_all_keys() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("hello"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::LocalStorageSet { + key: "a".to_string(), + value: "1".to_string(), + optional: false, + }, + BrowserStep::LocalStorageSet { + key: "b".to_string(), + value: "2".to_string(), + optional: false, + }, + BrowserStep::LocalStorageClear { optional: false }, + BrowserStep::Evaluate { + function: "() => localStorage.length".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::json!(0), + "expected localStorage.length == 0 after clear; got: {result}" + ); +} + +/// Test 6.4 — storage_state includes localStorage entries. +/// +/// Sets a localStorage key, then calls StorageState (path: None). The returned +/// `local_storage` map must include the key that was set. +/// +/// The actual shape of the StorageState result (path = None) is: +/// `{"cookies": [...], "local_storage": {"key": "value", ...}}` +#[tokio::test] +async fn storage_state_includes_local_storage_entries() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("hello"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::LocalStorageSet { + key: "pref".to_string(), + value: "compact".to_string(), + optional: false, + }, + BrowserStep::StorageState { + path: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + // storage_state returns {"cookies": [...], "local_storage": {"pref": "compact", ...}} + let ls = result + .get("local_storage") + .expect("storage_state result should have a 'local_storage' field"); + + assert_eq!( + ls["pref"].as_str(), + Some("compact"), + "expected local_storage['pref'] == 'compact'; got: {result}" + ); +} + +/// Test 6.5 — session_storage_set and session_storage_get round-trip. +/// +/// Sets a key in sessionStorage and then reads it back, verifying the value +/// matches exactly what was written. +#[tokio::test] +async fn session_storage_set_and_get_round_trip() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("hello"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::SessionStorageSet { + key: "token".to_string(), + value: "xyz".to_string(), + optional: false, + }, + BrowserStep::SessionStorageGet { + key: "token".to_string(), + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"].as_str(), + Some("xyz"), + "expected sessionStorage['token'] == 'xyz'; got: {result}" + ); +} + +/// Test 6.6 — session_storage_delete removes the key. +/// +/// Sets a key, deletes it, then evaluates sessionStorage.length to confirm the +/// key no longer exists. +#[tokio::test] +async fn session_storage_delete_removes_key() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("hello"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::SessionStorageSet { + key: "x".to_string(), + value: "1".to_string(), + optional: false, + }, + BrowserStep::SessionStorageDelete { + key: "x".to_string(), + optional: false, + }, + BrowserStep::Evaluate { + function: "() => sessionStorage.length".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::json!(0), + "expected sessionStorage.length == 0 after delete; got: {result}" + ); +} + +/// Test 6.7 — session_storage_clear wipes all keys. +/// +/// Sets two keys, clears sessionStorage, then evaluates sessionStorage.length +/// to confirm no entries remain. +#[tokio::test] +async fn session_storage_clear_wipes_all_keys() { + if skip_if_no_chrome() { + return; + } + + let _guard = common::chrome_lock().await; + + let mut routes = HashMap::new(); + routes.insert( + "GET /".to_string(), + Response::html("hello"), + ); + let server = spawn(routes).await; + + let data = PreparedBrowserCommand { + session_id: None, + headless: true, + timeout_ms: 30_000, + on_failure_screenshot: false, + steps: vec![ + BrowserStep::Navigate { + url: server.url("/"), + expected_status: None, + timeout_ms: None, + optional: false, + }, + BrowserStep::SessionStorageSet { + key: "a".to_string(), + value: "1".to_string(), + optional: false, + }, + BrowserStep::SessionStorageSet { + key: "b".to_string(), + value: "2".to_string(), + optional: false, + }, + BrowserStep::SessionStorageClear { optional: false }, + BrowserStep::Evaluate { + function: "() => sessionStorage.length".to_string(), + r#ref: None, + timeout_ms: None, + optional: false, + }, + ], + }; + + let result = execute(data).await.expect("execute should succeed"); + + assert_eq!( + result["value"], + serde_json::json!(0), + "expected sessionStorage.length == 0 after clear; got: {result}" + ); +} diff --git a/examples/browser/ai_navigate.hcl b/examples/browser/ai_navigate.hcl new file mode 100644 index 0000000..0b3f5db --- /dev/null +++ b/examples/browser/ai_navigate.hcl @@ -0,0 +1,114 @@ +version = 1 +provider = "browser" +categories = ["browser", "navigation"] + +command "snapshot" { + title = "Snapshot" + summary = "Get an accessibility tree snapshot of the current browser session" + description = "Returns the accessibility tree of the current page in a persistent browser session. Use the returned refs to click, fill, or interact with elements in subsequent commands." + + annotations { + mode = "read" + } + + param "session_id" { + type = "string" + required = true + description = "Browser session ID" + } + + operation { + protocol = "browser" + browser { + session_id = "{{ args.session_id }}" + steps = [{ action = "snapshot" }] + } + } + + result { + decode = "json" + output = "{{ result.text }}" + } +} + +command "click" { + title = "Click element" + summary = "Click an element in a persistent browser session by ref or CSS selector" + description = "Click an element identified by a ref (from a snapshot) or a CSS selector." + + annotations { + mode = "write" + } + + param "session_id" { + type = "string" + required = true + description = "Browser session ID" + } + + param "ref" { + type = "string" + required = false + description = "Accessibility ref from a prior snapshot (e.g. e8)" + } + + param "selector" { + type = "string" + required = false + description = "CSS selector (alternative to ref)" + } + + operation { + protocol = "browser" + browser { + session_id = "{{ args.session_id }}" + steps = [ + { action = "click", ref = "{{ args.ref }}", selector = "{{ args.selector }}" }, + { action = "snapshot" }, + ] + } + } + + result { + decode = "json" + output = "Clicked. Updated page state:\n{{ result.text | truncate(500) }}" + } +} + +command "navigate" { + title = "Navigate" + summary = "Navigate to a URL in a persistent browser session" + description = "Navigate the browser session to a URL and return the page snapshot." + + annotations { + mode = "write" + } + + param "session_id" { + type = "string" + required = true + description = "Browser session ID" + } + + param "url" { + type = "string" + required = true + description = "URL to navigate to" + } + + operation { + protocol = "browser" + browser { + session_id = "{{ args.session_id }}" + steps = [ + { action = "navigate", url = "{{ args.url }}" }, + { action = "snapshot" }, + ] + } + } + + result { + decode = "json" + output = "Navigated to {{ args.url }}. Page state:\n{{ result.text | truncate(500) }}" + } +} diff --git a/examples/browser/login.hcl b/examples/browser/login.hcl new file mode 100644 index 0000000..c9d23f1 --- /dev/null +++ b/examples/browser/login.hcl @@ -0,0 +1,74 @@ +version = 1 +provider = "browser" +categories = ["browser", "auth"] + +command "login" { + title = "Login" + summary = "Log into a site using a form" + description = "Fills email and password fields and submits the login form." + + annotations { + mode = "write" + secrets = ["site.password"] + } + + param "url" { + type = "string" + required = true + description = "Login page URL" + } + + param "email" { + type = "string" + required = true + description = "Email address" + } + + param "email_selector" { + type = "string" + required = false + default = "input[type=email]" + description = "CSS selector for email field" + } + + param "password_selector" { + type = "string" + required = false + default = "input[type=password]" + description = "CSS selector for password field" + } + + param "submit_selector" { + type = "string" + required = false + default = "button[type=submit]" + description = "CSS selector for submit button" + } + + param "session_id" { + type = "string" + required = false + description = "Session ID to persist the logged-in browser" + } + + operation { + protocol = "browser" + browser { + session_id = "{{ args.session_id }}" + headless = true + steps = [ + { action = "navigate", url = "{{ args.url }}" }, + { action = "fill", selector = "{{ args.email_selector }}", text = "{{ args.email }}" }, + { action = "fill", selector = "{{ args.password_selector }}", text = "{{ secrets['site.password'] }}" }, + { action = "click", selector = "{{ args.submit_selector }}" }, + { action = "wait_for", timeout_ms = 5000, text = "" }, + { action = "snapshot" }, + ] + } + } + + result { + decode = "json" + output = "Logged in. Page state:\n{{ result.text | truncate(500) }}" + } +} diff --git a/examples/browser/scrape.hcl b/examples/browser/scrape.hcl new file mode 100644 index 0000000..3183c91 --- /dev/null +++ b/examples/browser/scrape.hcl @@ -0,0 +1,35 @@ +version = 1 +provider = "browser" +categories = ["browser", "scraping"] + +command "get_text" { + title = "Get page text" + summary = "Extract visible text from a JavaScript-rendered page" + description = "Navigate to a URL and return document.body.innerText." + + annotations { + mode = "read" + } + + param "url" { + type = "string" + required = true + description = "URL to scrape" + } + + operation { + protocol = "browser" + browser { + headless = true + steps = [ + { action = "navigate", url = "{{ args.url }}" }, + { action = "evaluate", function = "() => document.body.innerText" }, + ] + } + } + + result { + decode = "json" + output = "{{ result.value }}" + } +} diff --git a/examples/browser/screenshot.hcl b/examples/browser/screenshot.hcl new file mode 100644 index 0000000..81e9b71 --- /dev/null +++ b/examples/browser/screenshot.hcl @@ -0,0 +1,35 @@ +version = 1 +provider = "browser" +categories = ["browser", "capture"] + +command "screenshot" { + title = "Screenshot" + summary = "Take a screenshot of a URL" + description = "Navigate to a URL and capture a full-page screenshot." + + annotations { + mode = "read" + } + + param "url" { + type = "string" + required = true + description = "URL to screenshot" + } + + operation { + protocol = "browser" + browser { + headless = true + steps = [ + { action = "navigate", url = "{{ args.url }}" }, + { action = "screenshot", full_page = true }, + ] + } + } + + result { + decode = "json" + output = "Screenshot saved to {{ result.path }}" + } +} diff --git a/site/content/docs/template-schema.mdx b/site/content/docs/template-schema.mdx index 53bfbea..f77e827 100644 --- a/site/content/docs/template-schema.mdx +++ b/site/content/docs/template-schema.mdx @@ -359,6 +359,174 @@ SQL operation-level fields: | `max_rows` | integer | Limit the number of rows returned. | | `max_time_ms` | integer | Query execution timeout in milliseconds. | +### Browser + +```hcl +operation { + protocol = "browser" + + browser { + session_id = "{{ args.session_id }}" + headless = true + timeout_ms = 30000 + on_failure_screenshot = true + steps = [ + { action = "navigate", url = "{{ args.url }}" }, + { action = "snapshot" }, + ] + } +} +``` + +Browser operation-level fields: + +| Field | Type | Required | Description | +| ---------- | ------ | -------- | --------------------- | +| `protocol` | string | yes | Must be `"browser"`. | +| `browser` | block | yes | Browser configuration. | + +`browser` inner block fields: + +| Field | Type | Default | Description | +| ---------------------- | --------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `steps` | list of objects | yes | Ordered list of step objects to execute. Each object must include an `action` field. | +| `session_id` | string | — | Stable identifier for a persistent browser session. Omit for a one-shot command. | +| `headless` | boolean | `true` | Run Chrome in headless mode. | +| `timeout_ms` | integer | `30000` | Global timeout in milliseconds for the entire command. | +| `on_failure_screenshot`| boolean | `true` | Capture a screenshot and attach it to the error output when any non-optional step fails. | + +Every step object has two cross-cutting optional fields in addition to its action-specific fields: + +| Field | Default | Description | +| ------------ | ------- | ----------------------------------------------------------------------- | +| `action` | — | Required. Identifies the step type. See step reference below. | +| `optional` | `false` | When `true`, failure on this step is ignored and execution continues. | +| `timeout_ms` | — | Per-step timeout in milliseconds, overrides the command-level timeout. | + +#### Step reference + +**Navigation** + +| Action | Key fields | Result shape | +| ----------------- | ----------------------------------------------------------- | ------------------------- | +| `navigate` | `url` (required), `expected_status`, `timeout_ms` | `{"ok": true}` | +| `navigate_back` | — | `{"ok": true}` | +| `navigate_forward`| — | `{"ok": true}` | +| `reload` | — | `{"ok": true}` | + +**Observation** + +| Action | Key fields | Result shape | +| ----------------- | --------------------------------------------------- | ------------------------------------------ | +| `snapshot` | — | `{"text": "", "raw": [...]}` | +| `screenshot` | `path`, `type` (`png`/`jpeg`), `full_page`, `ref` | `{"data": "", "path": "..."}` | +| `pdf_save` | `path` | `{"path": "..."}` | + +**Interaction** + +| Action | Key fields | Result shape | +| --------------- | -------------------------------------------------------------- | -------------- | +| `click` | `ref` or `selector`, `double_click` | `{"ok": true}` | +| `hover` | `ref` or `selector` | `{"ok": true}` | +| `fill` | `ref` or `selector`, `text`, `submit` | `{"ok": true}` | +| `fill_form` | `fields` (array of `{ref/selector, value, type}`) | `{"ok": true}` | +| `select_option` | `ref` or `selector`, `values` (array) | `{"ok": true}` | +| `check` | `ref` or `selector` | `{"ok": true}` | +| `uncheck` | `ref` or `selector` | `{"ok": true}` | +| `press_key` | `key` (e.g. `"Enter"`, `"Tab"`, `"Escape"`) | `{"ok": true}` | +| `drag` | `start_ref`/`start_selector`, `end_ref`/`end_selector` | `{"ok": true}` | +| `file_upload` | `ref` or `selector`, `paths` (array) | `{"ok": true}` | +| `handle_dialog` | `accept` (boolean), `prompt_text` | `{"ok": true, "accept": ...}` | + +**Mouse** + +| Action | Key fields | Result shape | +| ------------ | --------------------------------------- | -------------- | +| `mouse_move` | `x`, `y` | `{"ok": true}` | +| `mouse_click`| `x`, `y`, `button` | `{"ok": true}` | +| `mouse_drag` | `start_x`, `start_y`, `end_x`, `end_y` | `{"ok": true}` | +| `mouse_down` | `x`, `y`, `button` | `{"ok": true}` | +| `mouse_up` | `x`, `y`, `button` | `{"ok": true}` | +| `mouse_wheel`| `delta_x`, `delta_y` | `{"ok": true}` | + +**Wait and assertions** + +| Action | Key fields | Result shape | +| ----------------------- | ----------------------------------------------------- | -------------- | +| `wait_for` | `text`, `text_gone`, `time` (seconds), `timeout_ms` (optional — defaults to command `timeout_ms`) | `{"ok": true}` | +| `verify_text_visible` | `text` | `{"ok": true}` | +| `verify_element_visible`| `role`, `accessible_name` | `{"ok": true}` | +| `verify_list_visible` | `items` (array) | `{"ok": true}` | +| `verify_value` | `value` | `{"ok": true}` | + +**JavaScript** + +| Action | Key fields | Result shape | +| ---------- | --------------------------------------- | --------------------- | +| `evaluate` | `function` (JS arrow function string), `ref` | `{"value": ...}` | +| `run_code` | `code` (JS statements string) | `{"ok": true}` | + +**Cookies** + +| Action | Key fields | Result shape | +| --------------- | ----------------------------------------------------------------------- | ----------------------------- | +| `cookie_list` | `domain` (filter, optional) | `{"cookies": [...]}` | +| `cookie_get` | `name` | `{"value": "..."}` | +| `cookie_set` | `name`, `value`, `domain`, `path`, `expires`, `http_only`, `secure` | `{"ok": true}` | +| `cookie_delete` | `name` | `{"ok": true}` | +| `cookie_clear` | — | `{"ok": true}` | + +**Storage** + +| Action | Key fields | Result shape | +| ------------------------ | ------------------ | ----------------------- | +| `local_storage_get` | `key` | `{"value": "..."}` | +| `local_storage_set` | `key`, `value` | `{"ok": true}` | +| `local_storage_delete` | `key` | `{"ok": true}` | +| `local_storage_clear` | — | `{"ok": true}` | +| `session_storage_get` | `key` | `{"value": "..."}` | +| `session_storage_set` | `key`, `value` | `{"ok": true}` | +| `session_storage_delete` | `key` | `{"ok": true}` | +| `session_storage_clear` | — | `{"ok": true}` | +| `storage_state` | `path` (optional) | `{"cookies": [...], "local_storage": {...}}` | +| `set_storage_state` | `path` | `{"ok": true}` | + +**Tabs and viewport** + +| Action | Key fields | Result shape | +| -------- | ----------------------------------------------------- | ----------------------------- | +| `tabs` | `operation` (`list`/`new`/`close`/`select`), `index` | `{"tabs": [...]}` or `{"ok": true}` | +| `resize` | `width`, `height` | `{"ok": true}` | +| `close` | — | `{"ok": true}` | + +**Network (session mode only)** + +| Action | Key fields | Result shape | +| --------------- | ---------------------------------------------------- | ------------------------- | +| `route` | `pattern`, `status`, `body`, `content_type` | `{"ok": true}` | +| `route_list` | — | `{"routes": [...]}` | +| `unroute` | `pattern` | `{"ok": true}` | +| `console_messages` | — | `{"messages": [...]}` | +| `console_clear` | — | `{"ok": true}` | +| `network_requests` | — | `{"requests": [...]}` | +| `network_clear` | — | `{"ok": true}` | +| `download` | `path` | `{"path": "..."}` | + +**Recording** + +| Action | Key fields | Result shape | +| --------------- | ---------------------- | ------------------ | +| `start_video` | `width`, `height` | `{"ok": true}` | +| `stop_video` | — | `{"path": "..."}` | +| `start_tracing` | — | `{"ok": true}` | +| `stop_tracing` | — | `{"path": "..."}` | + +**Utility** + +| Action | Key fields | Result shape | +| ------------------ | ------------- | -------------------------- | +| `generate_locator` | `ref` | `{"selector": "..."}` | + ## `body` block (HTTP only) The `body` block inside an HTTP `operation` has a `kind` discriminator field. diff --git a/site/content/docs/templates.mdx b/site/content/docs/templates.mdx index c001aa9..72b0ebc 100644 --- a/site/content/docs/templates.mdx +++ b/site/content/docs/templates.mdx @@ -1,7 +1,7 @@ --- title: Writing Templates icon: FileCode2 -description: How Earl templates work — the mental model, file layout, five protocols, and the HCL/Jinja authoring rule you need to know. +description: How Earl templates work — the mental model, file layout, six protocols, and the HCL/Jinja authoring rule you need to know. --- Templates are HCL files that define what an AI agent is allowed to do with a provider. Every command an agent can invoke maps to exactly one `command` block. The LLM sees the command's title, summary, description, and parameter names. It never reads the template body — the URL, method, headers, auth setup, and request structure. That separation means the agent cannot alter the operation itself, only supply the parameter values the template author declared. @@ -152,7 +152,7 @@ Inside the `operation` block, parameters are available as `args.`: url = "https://api.github.com/repos/{{ args.owner }}/{{ args.repo }}" ``` -## The five protocols +## The six protocols ### HTTP @@ -276,6 +276,44 @@ operation { SQL parameters go through the driver's parameter interface, not string interpolation. Quote every Jinja expression in the array — HCL needs them as strings, but Earl coerces them to the right types before sending the query. Placeholder syntax is database-specific: `$1`, `$2`... for PostgreSQL; `?` for MySQL and SQLite. +### Browser + +```hcl +operation { + protocol = "browser" + + browser { + session_id = "{{ args.session_id }}" # optional; omit for one-shot + headless = true + timeout_ms = 30000 + on_failure_screenshot = true + steps = [ + { action = "navigate", url = "{{ args.url }}" }, + { action = "fill", selector = "#username", text = "{{ args.user }}" }, + { action = "fill", selector = "#password", text = "{{ secrets['site.password'] }}" }, + { action = "click", selector = "button[type=submit]" }, + { action = "wait_for", text = "Dashboard", timeout_ms = 5000 }, + { action = "snapshot" }, + ] + } +} +``` + +Each command runs a sequence of `steps`. Every step is an inline object with an `action` field and zero or more action-specific fields. The result of the command is the last step's output — a JSON object whose shape depends on the action (e.g. `snapshot` returns `{"text": "..."}`, `evaluate` returns `{"value": ...}`). + +Steps support two cross-cutting fields available on every action: + +| Field | Default | Description | +| ------------ | ------- | --------------------------------------------------------------------- | +| `optional` | `false` | When `true`, a failure on this step is skipped and execution continues. | +| `timeout_ms` | — | Per-step timeout, overrides the command-level `timeout_ms`. | + +**Session persistence** — set `session_id` to a stable string to keep the browser alive between commands. A subsequent command with the same `session_id` reconnects to the same browser instance, preserving cookies, localStorage, and page state. Omit `session_id` for one-shot commands that start and stop a fresh browser. + +The `snapshot` action returns an accessibility tree as a text description. Use the `ref` handles it contains to identify elements for subsequent `click`, `fill`, and other interaction steps without needing CSS selectors. + +For the full list of actions and their fields, see [Template Schema — Browser steps](/docs/template-schema#step-reference). + ## Auth ```hcl diff --git a/src/protocol/builder.rs b/src/protocol/builder.rs index 258449f..73dc5e4 100644 --- a/src/protocol/builder.rs +++ b/src/protocol/builder.rs @@ -63,6 +63,8 @@ pub enum PreparedProtocolData { Bash(PreparedBashScript), #[cfg(feature = "sql")] Sql(PreparedSqlQuery), + #[cfg(feature = "browser")] + Browser(earl_protocol_browser::PreparedBrowserCommand), } pub use earl_core::{PreparedBody, PreparedMultipartPart}; @@ -78,6 +80,9 @@ pub use earl_protocol_bash::PreparedBashScript; #[cfg(feature = "sql")] pub use earl_protocol_sql::PreparedSqlQuery; +#[cfg(feature = "browser")] +pub use earl_protocol_browser::PreparedBrowserCommand; + // ── Builder entry-points ───────────────────────────────────── #[allow(clippy::too_many_arguments)] @@ -378,6 +383,26 @@ where protocol_data: PreparedProtocolData::Sql(data), }) } + #[cfg(feature = "browser")] + OperationTemplate::Browser(browser_operation) => { + let data = earl_protocol_browser::builder::build_browser_request( + browser_operation, + &context, + &renderer, + )?; + + Ok(PreparedRequest { + key: entry.key.clone(), + mode: entry.mode, + stream: operation.is_streaming(), + allow_rules: Vec::new(), + transport, + result_template: result_template.clone(), + args, + redactor: Redactor::new(secret_values), + protocol_data: PreparedProtocolData::Browser(data), + }) + } _ => bail!("unsupported protocol (feature not enabled)"), } } diff --git a/src/protocol/executor.rs b/src/protocol/executor.rs index fd52952..9ca60fb 100644 --- a/src/protocol/executor.rs +++ b/src/protocol/executor.rs @@ -82,6 +82,12 @@ where .execute(sql_data, &to_context(prepared)) .await } + #[cfg(feature = "browser")] + PreparedProtocolData::Browser(browser_data) => { + earl_protocol_browser::BrowserExecutor + .execute(browser_data, &to_context(prepared)) + .await + } _ => Err(anyhow::anyhow!( "unsupported protocol (feature not enabled)" )), @@ -186,6 +192,10 @@ pub fn start_streaming_request( PreparedProtocolData::Sql(_) => { Err(anyhow::anyhow!("streaming not supported for SQL protocol")) } + #[cfg(feature = "browser")] + PreparedProtocolData::Browser(_) => Err(anyhow::anyhow!( + "streaming not supported for browser protocol" + )), _ => Err(anyhow::anyhow!( "unsupported protocol (feature not enabled)" )), diff --git a/src/template/schema.rs b/src/template/schema.rs index 14169e0..81e7fcf 100644 --- a/src/template/schema.rs +++ b/src/template/schema.rs @@ -93,6 +93,8 @@ pub enum OperationTemplate { Bash(BashOperationTemplate), #[cfg(feature = "sql")] Sql(SqlOperationTemplate), + #[cfg(feature = "browser")] + Browser(earl_protocol_browser::BrowserOperationTemplate), } impl OperationTemplate { @@ -109,6 +111,8 @@ impl OperationTemplate { OperationTemplate::Bash(_) => OperationProtocol::Bash, #[cfg(feature = "sql")] OperationTemplate::Sql(_) => OperationProtocol::Sql, + #[cfg(feature = "browser")] + OperationTemplate::Browser(_) => OperationProtocol::Browser, _ => unreachable!(), } } @@ -126,6 +130,8 @@ impl OperationTemplate { OperationTemplate::Bash(op) => op.transport.as_ref(), #[cfg(feature = "sql")] OperationTemplate::Sql(op) => op.transport.as_ref(), + #[cfg(feature = "browser")] + OperationTemplate::Browser(_) => None, _ => None, } } @@ -139,6 +145,8 @@ impl OperationTemplate { OperationTemplate::Graphql(op) => op.auth.as_ref(), #[cfg(feature = "grpc")] OperationTemplate::Grpc(op) => op.auth.as_ref(), + #[cfg(feature = "browser")] + OperationTemplate::Browser(_) => None, _ => None, } } @@ -152,6 +160,8 @@ impl OperationTemplate { OperationTemplate::Graphql(op) => Some(op.url.as_str()), #[cfg(feature = "grpc")] OperationTemplate::Grpc(op) => Some(op.url.as_str()), + #[cfg(feature = "browser")] + OperationTemplate::Browser(_) => None, _ => None, } } @@ -196,6 +206,8 @@ impl OperationTemplate { OperationTemplate::Bash(op) => op.stream, #[cfg(feature = "sql")] OperationTemplate::Sql(_) => false, + #[cfg(feature = "browser")] + OperationTemplate::Browser(_) => false, _ => false, } } @@ -214,6 +226,8 @@ pub enum OperationProtocol { Bash, #[cfg(feature = "sql")] Sql, + #[cfg(feature = "browser")] + Browser, } #[cfg(feature = "http")] @@ -233,3 +247,60 @@ pub use earl_protocol_bash::{BashOperationTemplate, BashSandboxTemplate, BashScr #[cfg(feature = "sql")] pub use earl_protocol_sql::{SqlOperationTemplate, SqlQueryTemplate, SqlSandboxTemplate}; + +// ── Browser ─────────────────────────────────────────── + +#[cfg(feature = "browser")] +pub use earl_protocol_browser::BrowserOperationTemplate; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_environments_deserializes_from_normalized_json() { + let json = serde_json::json!({ + "default": "production", + "secrets": ["myservice.prod_token"], + "environments": { + "production": { "base_url": "https://api.myservice.com" }, + "staging": { "base_url": "https://staging.myservice.com" } + } + }); + let pe: ProviderEnvironments = serde_json::from_value(json).unwrap(); + assert_eq!(pe.default.as_deref(), Some("production")); + assert_eq!(pe.secrets, vec!["myservice.prod_token"]); + assert_eq!( + pe.environments["production"]["base_url"], + "https://api.myservice.com" + ); + assert_eq!( + pe.environments["staging"]["base_url"], + "https://staging.myservice.com" + ); + } + + #[test] + fn provider_environments_defaults_work() { + let json = serde_json::json!({ + "environments": { "staging": { "url": "https://staging.example.com" } } + }); + let pe: ProviderEnvironments = serde_json::from_value(json).unwrap(); + assert!(pe.default.is_none()); + assert!(pe.secrets.is_empty()); + assert!(pe.environments.contains_key("staging")); + } + + #[cfg(feature = "browser")] + #[test] + fn browser_operation_deserializes() { + let json = r#"{ + "protocol": "browser", + "browser": { + "steps": [{"action":"navigate","url":"https://example.com"}] + } + }"#; + let op: OperationTemplate = serde_json::from_str(json).unwrap(); + assert!(matches!(op, OperationTemplate::Browser(_))); + } +} diff --git a/src/template/validator.rs b/src/template/validator.rs index 058d582..4df60f7 100644 --- a/src/template/validator.rs +++ b/src/template/validator.rs @@ -227,6 +227,8 @@ fn validate_operation( OperationTemplate::Bash(op) => validate_bash_operation(command_name, op), #[cfg(feature = "sql")] OperationTemplate::Sql(op) => validate_sql_operation(command_name, op, allowed_secrets), + #[cfg(feature = "browser")] + OperationTemplate::Browser(_) => Ok(()), _ => bail!("unsupported protocol (feature not enabled)"), } }