//! End-to-end tests for the native daemon. //! //! These tests launch a real Chrome instance and exercise the full command //! pipeline. They require Chrome to be installed and are marked `#[ignore]` //! so they don't run during normal `cargo test`. //! //! Run serially to avoid Chrome instance contention: //! cargo test e2e -- --ignored --test-threads=1 use serde_json::{json, Value}; use super::actions::{execute_command, DaemonState}; fn assert_success(resp: &Value) { assert_eq!( resp.get("success").and_then(|v| v.as_bool()), Some(true), "Expected success but got: {}", serde_json::to_string_pretty(resp).unwrap_or_default() ); } fn get_data(resp: &Value) -> &Value { resp.get("data").expect("Missing 'data' in response") } // --------------------------------------------------------------------------- // Core: launch, navigate, evaluate, url, title, close // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_launch_navigate_evaluate_close() { let mut state = DaemonState::new(); // Launch headless Chrome let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["launched"], true); // Navigate to example.com let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["url"], "https://example.com/"); assert_eq!(get_data(&resp)["title"], "Example Domain"); // Get URL let resp = execute_command(&json!({ "id": "3", "action": "url" }), &mut state).await; assert_success(&resp); assert_eq!(get_data(&resp)["url"], "https://example.com/"); // Get title let resp = execute_command(&json!({ "id": "4", "action": "title" }), &mut state).await; assert_success(&resp); assert_eq!(get_data(&resp)["title"], "Example Domain"); // Evaluate JS let resp = execute_command( &json!({ "id": "5", "action": "evaluate", "script": "1 + 2" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"], 3); // Evaluate document.title let resp = execute_command( &json!({ "id": "6", "action": "evaluate", "script": "document.title" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"], "Example Domain"); // Close let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); assert_eq!(get_data(&resp)["closed"], true); } #[tokio::test] #[ignore] async fn e2e_lightpanda_launch_can_open_page() { let lightpanda_bin = match std::env::var("LIGHTPANDA_BIN") { Ok(path) if !path.is_empty() => path, _ => return, }; let mut state = DaemonState::new(); let resp = tokio::time::timeout( tokio::time::Duration::from_secs(20), execute_command( &json!({ "id": "1", "action": "launch", "headless": true, "engine": "lightpanda", "executablePath": lightpanda_bin, }), &mut state, ), ) .await .expect("Lightpanda launch should not hang"); assert_success(&resp); assert_eq!(get_data(&resp)["launched"], true); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["url"], "https://example.com/"); assert_eq!(get_data(&resp)["title"], "Example Domain"); let resp = execute_command(&json!({ "id": "3", "action": "close" }), &mut state).await; assert_success(&resp); assert_eq!(get_data(&resp)["closed"], true); } #[tokio::test] #[ignore] async fn e2e_lightpanda_auto_launch_can_open_page() { let lightpanda_bin = match std::env::var("LIGHTPANDA_BIN") { Ok(path) if !path.is_empty() => path, _ => return, }; let prev_engine = std::env::var("AGENT_BROWSER_ENGINE").ok(); let prev_path = std::env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(); std::env::set_var("AGENT_BROWSER_ENGINE", "lightpanda"); std::env::set_var("AGENT_BROWSER_EXECUTABLE_PATH", &lightpanda_bin); let mut state = DaemonState::new(); let resp = tokio::time::timeout( tokio::time::Duration::from_secs(20), execute_command( &json!({ "id": "1", "action": "navigate", "url": "https://example.com" }), &mut state, ), ) .await .expect("Lightpanda auto-launch should not hang"); match prev_engine { Some(value) => std::env::set_var("AGENT_BROWSER_ENGINE", value), None => std::env::remove_var("AGENT_BROWSER_ENGINE"), } match prev_path { Some(value) => std::env::set_var("AGENT_BROWSER_EXECUTABLE_PATH", value), None => std::env::remove_var("AGENT_BROWSER_EXECUTABLE_PATH"), } assert_success(&resp); assert_eq!(get_data(&resp)["url"], "https://example.com/"); assert_eq!(get_data(&resp)["title"], "Example Domain"); let resp = execute_command(&json!({ "id": "2", "action": "close" }), &mut state).await; assert_success(&resp); assert_eq!(get_data(&resp)["closed"], true); } // --------------------------------------------------------------------------- // Snapshot with refs and ref-based click // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_snapshot_and_click_ref() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); // Take snapshot let resp = execute_command(&json!({ "id": "3", "action": "snapshot" }), &mut state).await; assert_success(&resp); let snapshot = get_data(&resp)["snapshot"].as_str().unwrap(); assert!( snapshot.contains("Example Domain"), "Snapshot should contain heading" ); assert!(snapshot.contains("ref=e1"), "Snapshot should have ref e1"); assert!(snapshot.contains("ref=e2"), "Snapshot should have ref e2"); assert!( snapshot.contains("link"), "Snapshot should have a link element" ); // Click the link by ref (e2 is the "More information..." link) let resp = execute_command( &json!({ "id": "4", "action": "click", "selector": "e2" }), &mut state, ) .await; assert_success(&resp); // Wait for navigation tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; // Verify URL changed let resp = execute_command(&json!({ "id": "5", "action": "url" }), &mut state).await; assert_success(&resp); let url = get_data(&resp)["url"].as_str().unwrap(); assert!( url.contains("iana.org"), "Should have navigated to iana.org, got: {}", url ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Screenshot // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_screenshot() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); // Default screenshot let resp = execute_command(&json!({ "id": "3", "action": "screenshot" }), &mut state).await; assert_success(&resp); let path = get_data(&resp)["path"].as_str().unwrap(); assert!(path.ends_with(".png"), "Screenshot path should be .png"); let metadata = std::fs::metadata(path).expect("Screenshot file should exist"); assert!( metadata.len() > 1000, "Screenshot should be non-trivial size" ); // Named screenshot let tmp_path = std::env::temp_dir() .join("agent-browser-e2e-test-screenshot.png") .to_string_lossy() .to_string(); let resp = execute_command( &json!({ "id": "4", "action": "screenshot", "path": tmp_path }), &mut state, ) .await; assert_success(&resp); assert!(std::path::Path::new(&tmp_path).exists()); let _ = std::fs::remove_file(&tmp_path); let resp = execute_command( &json!({ "id": "5", "action": "setcontent", "html": r##" Home
"##, }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "6", "action": "screenshot", "annotate": true }), &mut state, ) .await; assert_success(&resp); let annotations = get_data(&resp)["annotations"] .as_array() .expect("Annotated screenshot should return annotations"); assert!( !annotations.is_empty(), "Annotated screenshot should have at least one annotation" ); let submit_ref = annotations .iter() .find(|ann| ann.get("name").and_then(|v| v.as_str()) == Some("Submit")) .and_then(|ann| ann.get("ref").and_then(|v| v.as_str())) .expect("Expected a Submit annotation"); let resp = execute_command( &json!({ "id": "7", "action": "evaluate", "script": "document.getElementById('__agent_browser_annotations__') === null" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"], true); let resp = execute_command( &json!({ "id": "8", "action": "click", "selector": format!("@{}", submit_ref) }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "9", "action": "evaluate", "script": "document.getElementById('result').textContent" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"], "clicked"); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Form interaction: fill, type, select, check // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_form_interaction() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let html = concat!( "data:text/html,", "", "", "", "", "", "", "" ); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": html }), &mut state, ) .await; assert_success(&resp); // Fill name let resp = execute_command( &json!({ "id": "10", "action": "fill", "selector": "#name", "value": "John Doe" }), &mut state, ) .await; assert_success(&resp); // Verify fill let resp = execute_command( &json!({ "id": "11", "action": "evaluate", "script": "document.getElementById('name').value" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"], "John Doe"); // Type email – the type action now correctly handles punctuation like '.' let resp = execute_command( &json!({ "id": "12", "action": "type", "selector": "#email", "text": "john@example.com" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "13", "action": "evaluate", "script": "document.getElementById('email').value" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"], "john@example.com"); // Select option let resp = execute_command( &json!({ "id": "14", "action": "select", "selector": "#color", "values": ["blue"] }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "15", "action": "evaluate", "script": "document.getElementById('color').value" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"], "blue"); // Check checkbox let resp = execute_command( &json!({ "id": "16", "action": "check", "selector": "#agree" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "17", "action": "ischecked", "selector": "#agree" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["checked"], true); // Uncheck let resp = execute_command( &json!({ "id": "18", "action": "uncheck", "selector": "#agree" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "19", "action": "ischecked", "selector": "#agree" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["checked"], false); // Snapshot should show form state let resp = execute_command(&json!({ "id": "20", "action": "snapshot" }), &mut state).await; assert_success(&resp); let snap = get_data(&resp)["snapshot"].as_str().unwrap(); assert!( snap.contains("John Doe"), "Snapshot should show filled value" ); assert!(snap.contains("textbox"), "Snapshot should show textbox"); assert!(snap.contains("button"), "Snapshot should show button"); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Navigation: back, forward, reload // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_navigation_history() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Navigate to page 1 let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "data:text/html,

Page 1

" }), &mut state, ) .await; assert_success(&resp); // Navigate to page 2 let resp = execute_command( &json!({ "id": "3", "action": "navigate", "url": "data:text/html,

Page 2

" }), &mut state, ) .await; assert_success(&resp); // Back let resp = execute_command(&json!({ "id": "4", "action": "back" }), &mut state).await; assert_success(&resp); let resp = execute_command( &json!({ "id": "5", "action": "evaluate", "script": "document.querySelector('h1').textContent" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"], "Page 1"); // Forward let resp = execute_command(&json!({ "id": "6", "action": "forward" }), &mut state).await; assert_success(&resp); let resp = execute_command( &json!({ "id": "7", "action": "evaluate", "script": "document.querySelector('h1').textContent" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"], "Page 2"); // Reload let resp = execute_command(&json!({ "id": "8", "action": "reload" }), &mut state).await; assert_success(&resp); let resp = execute_command( &json!({ "id": "9", "action": "evaluate", "script": "document.querySelector('h1').textContent" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"], "Page 2"); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Cookies // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_cookies() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); // Set cookie let resp = execute_command( &json!({ "id": "3", "action": "cookies_set", "name": "test_cookie", "value": "hello123" }), &mut state, ) .await; assert_success(&resp); // Get cookies let resp = execute_command(&json!({ "id": "4", "action": "cookies_get" }), &mut state).await; assert_success(&resp); let cookies = get_data(&resp)["cookies"].as_array().unwrap(); let found = cookies .iter() .any(|c| c["name"] == "test_cookie" && c["value"] == "hello123"); assert!(found, "Should find the set cookie"); // Clear cookies let resp = execute_command(&json!({ "id": "5", "action": "cookies_clear" }), &mut state).await; assert_success(&resp); // Verify cleared let resp = execute_command(&json!({ "id": "6", "action": "cookies_get" }), &mut state).await; assert_success(&resp); let cookies = get_data(&resp)["cookies"].as_array().unwrap(); let found = cookies.iter().any(|c| c["name"] == "test_cookie"); assert!(!found, "Cookie should be cleared"); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // localStorage / sessionStorage // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_storage() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); // Set local storage let resp = execute_command( &json!({ "id": "3", "action": "storage_set", "type": "local", "key": "mykey", "value": "myvalue" }), &mut state, ) .await; assert_success(&resp); // Get local storage key let resp = execute_command( &json!({ "id": "4", "action": "storage_get", "type": "local", "key": "mykey" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["value"], "myvalue"); // Get all local storage let resp = execute_command( &json!({ "id": "5", "action": "storage_get", "type": "local" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["data"]["mykey"], "myvalue"); // Clear let resp = execute_command( &json!({ "id": "6", "action": "storage_clear", "type": "local" }), &mut state, ) .await; assert_success(&resp); // Verify cleared let resp = execute_command( &json!({ "id": "7", "action": "storage_get", "type": "local" }), &mut state, ) .await; assert_success(&resp); let data = &get_data(&resp)["data"]; assert!( data.as_object().map(|m| m.is_empty()).unwrap_or(true), "Storage should be empty after clear" ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Tab management // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_tabs() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "data:text/html,

Tab 1

" }), &mut state, ) .await; assert_success(&resp); // Tab list should show 1 tab let resp = execute_command(&json!({ "id": "3", "action": "tab_list" }), &mut state).await; assert_success(&resp); let tabs = get_data(&resp)["tabs"].as_array().unwrap(); assert_eq!(tabs.len(), 1); assert_eq!(tabs[0]["active"], true); // Open new tab let resp = execute_command( &json!({ "id": "4", "action": "tab_new", "url": "data:text/html,

Tab 2

" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["index"], 1); // Tab list should show 2 tabs let resp = execute_command(&json!({ "id": "5", "action": "tab_list" }), &mut state).await; assert_success(&resp); let tabs = get_data(&resp)["tabs"].as_array().unwrap(); assert_eq!(tabs.len(), 2); assert_eq!(tabs[1]["active"], true); // Switch to first tab let resp = execute_command( &json!({ "id": "6", "action": "tab_switch", "index": 0 }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "7", "action": "evaluate", "script": "document.querySelector('h1').textContent" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"], "Tab 1"); // Close second tab let resp = execute_command( &json!({ "id": "8", "action": "tab_close", "index": 1 }), &mut state, ) .await; assert_success(&resp); // Should have 1 tab left let resp = execute_command(&json!({ "id": "9", "action": "tab_list" }), &mut state).await; assert_success(&resp); let tabs = get_data(&resp)["tabs"].as_array().unwrap(); assert_eq!(tabs.len(), 1); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Element queries: isvisible, isenabled, gettext, getattribute // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_element_queries() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let html = concat!( "data:text/html,", "

Hello World

", "", "", "", "Click me", "" ); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": html }), &mut state, ) .await; assert_success(&resp); // isvisible let resp = execute_command( &json!({ "id": "3", "action": "isvisible", "selector": "#visible" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["visible"], true); let resp = execute_command( &json!({ "id": "4", "action": "isvisible", "selector": "#hidden" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["visible"], false); // isenabled let resp = execute_command( &json!({ "id": "5", "action": "isenabled", "selector": "#enabled" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["enabled"], true); let resp = execute_command( &json!({ "id": "6", "action": "isenabled", "selector": "#disabled" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["enabled"], false); // gettext let resp = execute_command( &json!({ "id": "7", "action": "gettext", "selector": "#visible" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["text"], "Hello World"); // getattribute let resp = execute_command( &json!({ "id": "8", "action": "getattribute", "selector": "#link", "attribute": "href" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["value"], "https://example.com"); let resp = execute_command( &json!({ "id": "9", "action": "getattribute", "selector": "#link", "attribute": "data-testid" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["value"], "my-link"); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Wait command // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_wait() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let html = concat!( "data:text/html,", "", "", "" ); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": html }), &mut state, ) .await; assert_success(&resp); // Wait for selector to become visible let resp = execute_command( &json!({ "id": "3", "action": "wait", "selector": "#target", "state": "visible", "timeout": 5000 }), &mut state, ) .await; assert_success(&resp); // Wait for text let resp = execute_command( &json!({ "id": "4", "action": "wait", "text": "Appeared!", "timeout": 5000 }), &mut state, ) .await; assert_success(&resp); // Timeout wait let start = std::time::Instant::now(); let resp = execute_command( &json!({ "id": "5", "action": "wait", "timeout": 200 }), &mut state, ) .await; assert_success(&resp); assert!( start.elapsed().as_millis() >= 150, "Timeout wait should sleep at least 150ms" ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Viewport with deviceScaleFactor (retina) // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_viewport_scale_factor() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "about:blank" }), &mut state, ) .await; assert_success(&resp); // Default devicePixelRatio should be 1 let resp = execute_command( &json!({ "id": "3", "action": "evaluate", "script": "window.devicePixelRatio" }), &mut state, ) .await; assert_success(&resp); let default_dpr = get_data(&resp)["result"].as_f64().unwrap(); assert_eq!(default_dpr, 1.0, "Default devicePixelRatio should be 1"); // Set viewport with 2x scale factor let resp = execute_command( &json!({ "id": "4", "action": "viewport", "width": 1920, "height": 1080, "deviceScaleFactor": 2.0 }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["width"], 1920); assert_eq!(get_data(&resp)["height"], 1080); assert_eq!(get_data(&resp)["deviceScaleFactor"], 2.0); // devicePixelRatio should now be 2 let resp = execute_command( &json!({ "id": "5", "action": "evaluate", "script": "window.devicePixelRatio" }), &mut state, ) .await; assert_success(&resp); let new_dpr = get_data(&resp)["result"].as_f64().unwrap(); assert_eq!( new_dpr, 2.0, "devicePixelRatio should be 2 after setting scale factor" ); // CSS viewport width should still be 1920 (not 3840) let resp = execute_command( &json!({ "id": "6", "action": "evaluate", "script": "window.innerWidth" }), &mut state, ) .await; assert_success(&resp); let css_width = get_data(&resp)["result"].as_i64().unwrap(); assert_eq!(css_width, 1920, "CSS width should remain 1920 at 2x scale"); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Viewport and emulation // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_viewport_emulation() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "data:text/html,

Viewport

" }), &mut state, ) .await; assert_success(&resp); // Get initial width let resp = execute_command( &json!({ "id": "3", "action": "evaluate", "script": "window.innerWidth" }), &mut state, ) .await; assert_success(&resp); let initial_width = get_data(&resp)["result"].as_i64().unwrap(); // Set viewport to a different size let resp = execute_command( &json!({ "id": "4", "action": "viewport", "width": 375, "height": 812, "deviceScaleFactor": 3.0, "mobile": true }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["width"], 375); assert_eq!(get_data(&resp)["height"], 812); assert_eq!(get_data(&resp)["mobile"], true); // Reload to apply viewport change let resp = execute_command(&json!({ "id": "5", "action": "reload" }), &mut state).await; assert_success(&resp); // Width should differ from default (setDeviceMetricsOverride applied) let resp = execute_command( &json!({ "id": "6", "action": "evaluate", "script": "window.innerWidth" }), &mut state, ) .await; assert_success(&resp); let new_width = get_data(&resp)["result"].as_i64().unwrap(); assert!( new_width != initial_width || new_width == 375, "Viewport should change from {} after setDeviceMetricsOverride (got {})", initial_width, new_width ); // Set user agent let resp = execute_command( &json!({ "id": "5", "action": "user_agent", "userAgent": "TestBot/1.0" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "6", "action": "evaluate", "script": "navigator.userAgent" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"], "TestBot/1.0"); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Hover, scroll, press // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_hover_scroll_press() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let html = concat!( "data:text/html,", "", "", "" ); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": html }), &mut state, ) .await; assert_success(&resp); // Hover let resp = execute_command( &json!({ "id": "3", "action": "hover", "selector": "#btn" }), &mut state, ) .await; assert_success(&resp); // Scroll let resp = execute_command( &json!({ "id": "4", "action": "scroll", "y": 500 }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "5", "action": "evaluate", "script": "window.scrollY" }), &mut state, ) .await; assert_success(&resp); let scroll_y = get_data(&resp)["result"].as_f64().unwrap(); assert!(scroll_y > 0.0, "Should have scrolled down"); // Press key let resp = execute_command( &json!({ "id": "6", "action": "press", "key": "Enter" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["pressed"], "Enter"); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // State save/load, state management // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_state_management() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); // Set some storage let resp = execute_command( &json!({ "id": "3", "action": "storage_set", "type": "local", "key": "persist_key", "value": "persist_val" }), &mut state, ) .await; assert_success(&resp); // Save state let tmp_state = std::env::temp_dir() .join("agent-browser-e2e-state.json") .to_string_lossy() .to_string(); let resp = execute_command( &json!({ "id": "4", "action": "state_save", "path": &tmp_state }), &mut state, ) .await; assert_success(&resp); assert!(std::path::Path::new(&tmp_state).exists()); // State show let resp = execute_command( &json!({ "id": "5", "action": "state_show", "path": &tmp_state }), &mut state, ) .await; assert_success(&resp); let state_data = get_data(&resp); assert!(state_data.get("state").is_some()); // State list let resp = execute_command(&json!({ "id": "6", "action": "state_list" }), &mut state).await; assert_success(&resp); assert!(get_data(&resp)["files"].is_array()); // Clean up let _ = std::fs::remove_file(&tmp_state); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Domain filter // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_domain_filter() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Set domain filter after launch to avoid Fetch.enable deadlock in tests. state.domain_filter = Some(super::network::DomainFilter::new("example.com")); // Allowed domain let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); // Blocked domain let resp = execute_command( &json!({ "id": "3", "action": "navigate", "url": "https://blocked.com" }), &mut state, ) .await; assert_eq!(resp["success"], false); let error = resp["error"].as_str().unwrap(); assert!( error.contains("blocked") || error.contains("not allowed"), "Should reject blocked domain, got: {}", error ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Diff engine // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_diff_snapshot() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "data:text/html,

Hello

World

" }), &mut state, ) .await; assert_success(&resp); // Take a snapshot and use it as baseline for diff let resp = execute_command(&json!({ "id": "3", "action": "snapshot" }), &mut state).await; assert_success(&resp); let baseline = get_data(&resp)["snapshot"].as_str().unwrap().to_string(); // Modify the page let resp = execute_command( &json!({ "id": "4", "action": "evaluate", "script": "document.querySelector('h1').textContent = 'Changed'" }), &mut state, ) .await; assert_success(&resp); // Diff against baseline let resp = execute_command( &json!({ "id": "5", "action": "diff_snapshot", "baseline": baseline }), &mut state, ) .await; assert_success(&resp); let data = get_data(&resp); assert_eq!(data["changed"], true, "Diff should detect the h1 change"); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Phase 8 commands: focus, clear, count, boundingbox, innertext, setvalue // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_phase8_commands() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let html = concat!( "data:text/html,", "", "", "

One

", "

Two

", "

Three

", "
Box
", "" ); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": html }), &mut state, ) .await; assert_success(&resp); // Focus let resp = execute_command( &json!({ "id": "10", "action": "focus", "selector": "#a" }), &mut state, ) .await; assert_success(&resp); // Clear let resp = execute_command( &json!({ "id": "11", "action": "clear", "selector": "#a" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "12", "action": "evaluate", "script": "document.getElementById('a').value" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"], ""); // Set value let resp = execute_command( &json!({ "id": "13", "action": "setvalue", "selector": "#b", "value": "new-value" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "14", "action": "inputvalue", "selector": "#b" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["value"], "new-value"); // Count let resp = execute_command( &json!({ "id": "15", "action": "count", "selector": ".item" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["count"], 3); // Bounding box let resp = execute_command( &json!({ "id": "16", "action": "boundingbox", "selector": "#box" }), &mut state, ) .await; assert_success(&resp); let bbox = get_data(&resp); assert_eq!(bbox["width"], 200.0); assert_eq!(bbox["height"], 100.0); assert!(bbox["x"].as_f64().is_some()); assert!(bbox["y"].as_f64().is_some()); // Inner text let resp = execute_command( &json!({ "id": "17", "action": "innertext", "selector": "#box" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["text"], "Box"); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Auto-launch (tests that commands auto-launch when no browser exists) // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_auto_launch() { let mut state = DaemonState::new(); // Navigate without explicit launch -- should auto-launch let resp = execute_command( &json!({ "id": "1", "action": "navigate", "url": "data:text/html,

Auto

" }), &mut state, ) .await; assert_success(&resp); assert!(state.browser.is_some(), "Browser should be auto-launched"); let resp = execute_command( &json!({ "id": "2", "action": "evaluate", "script": "document.querySelector('h1').textContent" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"], "Auto"); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Error handling // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_error_handling() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "data:text/html,

Errors

" }), &mut state, ) .await; assert_success(&resp); // Unknown action let resp = execute_command( &json!({ "id": "10", "action": "nonexistent_action" }), &mut state, ) .await; assert_eq!(resp["success"], false); assert!(resp["error"] .as_str() .unwrap() .contains("Not yet implemented")); // Missing required parameter let resp = execute_command( &json!({ "id": "11", "action": "fill", "selector": "#x" }), &mut state, ) .await; assert_eq!(resp["success"], false); assert!(resp["error"].as_str().unwrap().contains("value")); // Click on non-existent element let resp = execute_command( &json!({ "id": "12", "action": "click", "selector": "#does-not-exist" }), &mut state, ) .await; assert_eq!(resp["success"], false); // Evaluate syntax error let resp = execute_command( &json!({ "id": "13", "action": "evaluate", "script": "}{invalid" }), &mut state, ) .await; assert_eq!(resp["success"], false); assert!(resp["error"].as_str().unwrap().contains("error")); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Profile cookie persistence across restarts // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_profile_cookie_persistence() { let profile_dir = std::env::temp_dir().join(format!( "agent-browser-e2e-profile-{}", uuid::Uuid::new_v4() )); // Session 1: launch with profile, set a cookie, close { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true, "profile": profile_dir.to_str().unwrap() }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "3", "action": "cookies_set", "name": "persist_test", "value": "should_survive_restart", "domain": ".example.com", "path": "/", "expires": 2000000000 }), &mut state, ) .await; assert_success(&resp); // Verify cookie is set let resp = execute_command(&json!({ "id": "4", "action": "cookies_get" }), &mut state).await; assert_success(&resp); let cookies = get_data(&resp)["cookies"].as_array().unwrap(); let found = cookies .iter() .any(|c| c["name"] == "persist_test" && c["value"] == "should_survive_restart"); assert!(found, "Cookie should exist before close"); let resp = execute_command(&json!({ "id": "5", "action": "close" }), &mut state).await; assert_success(&resp); } tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; // Session 2: reopen with the same profile, verify cookie persisted { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "10", "action": "launch", "headless": true, "profile": profile_dir.to_str().unwrap() }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "11", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command(&json!({ "id": "12", "action": "cookies_get" }), &mut state).await; assert_success(&resp); let cookies = get_data(&resp)["cookies"].as_array().unwrap(); let found = cookies .iter() .any(|c| c["name"] == "persist_test" && c["value"] == "should_survive_restart"); assert!( found, "Cookie should persist across restart with --profile. Cookies found: {:?}", cookies .iter() .map(|c| c["name"].as_str().unwrap_or("?")) .collect::>() ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } let _ = std::fs::remove_dir_all(&profile_dir); } // --------------------------------------------------------------------------- // Inspect / CDP URL // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_get_cdp_url() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let resp = execute_command(&json!({ "id": "2", "action": "cdp_url" }), &mut state).await; assert_success(&resp); let cdp_url = get_data(&resp)["cdpUrl"] .as_str() .expect("cdpUrl should be a string"); assert!( cdp_url.starts_with("ws://"), "CDP URL should start with ws://, got: {}", cdp_url ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } #[tokio::test] #[ignore] async fn e2e_inspect() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command(&json!({ "id": "3", "action": "inspect" }), &mut state).await; assert_success(&resp); let data = get_data(&resp); assert_eq!(data["opened"], true); let url = data["url"] .as_str() .expect("inspect url should be a string"); assert!( url.starts_with("http://127.0.0.1:"), "Inspect URL should be http://127.0.0.1:, got: {}", url ); // Verify the HTTP redirect serves a 302 to the DevTools frontend let http_resp = reqwest::get(url).await; match http_resp { Ok(r) => { let final_url = r.url().to_string(); assert!( final_url.contains("devtools/devtools_app.html"), "Redirect should point to DevTools frontend, got: {}", final_url ); } Err(e) => { panic!("HTTP GET to inspect URL failed: {}", e); } } let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Stale ref fallback (#805): clicking a ref after the DOM has been replaced // should fall back to role/name lookup instead of failing. // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_click_stale_ref_falls_back_to_role_name() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Navigate to a page with a button that replaces the DOM when clicked. let html = r#"data:text/html,
"#; let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": html }), &mut state, ) .await; assert_success(&resp); // Snapshot to populate the ref_map with backend_node_ids. let resp = execute_command(&json!({ "id": "3", "action": "snapshot" }), &mut state).await; assert_success(&resp); let snapshot = get_data(&resp)["snapshot"].as_str().unwrap(); assert!( snapshot.contains("Replace"), "Snapshot should contain Replace button" ); assert!( snapshot.contains("Target"), "Snapshot should contain Target button" ); // Click "Replace" — this removes all DOM nodes and recreates them, // making the backend_node_id for "Target" stale. let resp = execute_command( &json!({ "id": "4", "action": "click", "selector": "e1" }), &mut state, ) .await; assert_success(&resp); tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; // Verify the DOM was actually replaced. let resp = execute_command(&json!({ "id": "5", "action": "title" }), &mut state).await; assert_success(&resp); assert_eq!(get_data(&resp)["title"], "replaced"); // Now click the stale "Target" ref. Before the fix this returned: // "CDP error (DOM.getBoxModel): Could not compute box model." // After the fix it falls back to role/name lookup and succeeds. let resp = execute_command( &json!({ "id": "6", "action": "click", "selector": "e2" }), &mut state, ) .await; assert_success(&resp); tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; // Verify the fallback click hit the right (recreated) button. let resp = execute_command(&json!({ "id": "7", "action": "title" }), &mut state).await; assert_success(&resp); assert_eq!( get_data(&resp)["title"], "clicked", "Stale ref should have been resolved via role/name fallback" ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Regression: Material Design checkbox/radio (#832) // // Material Design controls hide the native off-screen and place // overlay elements (ripple, touch-target) on top. Coordinate-based CDP // clicks may therefore miss the actual input. The check/uncheck actions // must detect this and fall back to a JS .click() — matching the behaviour // that Playwright provided in v0.19.0. // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_material_checkbox_check_uncheck() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Inline HTML that reproduces the Material Design DOM pattern: // - Native is visually hidden (position:absolute, opacity:0, off-screen) // - A ripple overlay sits on top with pointer-events:all, intercepting coordinate clicks // - An ARIA-only checkbox uses role="checkbox" + aria-checked (no native input) let html = concat!( "data:text/html,", // -- Native baseline -- "", // -- Material-style hidden-input checkbox -- "
", "", "
", "Material CB", "
", // -- ARIA-only checkbox (no native input) -- "", "", "" ); let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": html }), &mut state, ) .await; assert_success(&resp); // ---- Native checkbox (sanity baseline) ---- let resp = execute_command( &json!({ "id": "10", "action": "ischecked", "selector": "#native" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["checked"], false); let resp = execute_command( &json!({ "id": "11", "action": "check", "selector": "#native" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "12", "action": "ischecked", "selector": "#native" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["checked"], true, "native check failed"); // ---- Material checkbox (hidden input + overlay) ---- // ischecked on the wrapper should detect the nested hidden input's state let resp = execute_command( &json!({ "id": "20", "action": "ischecked", "selector": "#mat" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["checked"], false); let resp = execute_command( &json!({ "id": "21", "action": "check", "selector": "#mat" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "22", "action": "ischecked", "selector": "#mat" }), &mut state, ) .await; assert_success(&resp); assert_eq!( get_data(&resp)["checked"], true, "Material checkbox should be checked after check action (#832)" ); // Idempotency: check again should be a no-op let resp = execute_command( &json!({ "id": "23", "action": "check", "selector": "#mat" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "24", "action": "ischecked", "selector": "#mat" }), &mut state, ) .await; assert_success(&resp); assert_eq!( get_data(&resp)["checked"], true, "Material checkbox should stay checked on redundant check" ); // Uncheck let resp = execute_command( &json!({ "id": "25", "action": "uncheck", "selector": "#mat" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "26", "action": "ischecked", "selector": "#mat" }), &mut state, ) .await; assert_success(&resp); assert_eq!( get_data(&resp)["checked"], false, "Material checkbox should be unchecked after uncheck action" ); // ---- ARIA-only checkbox ---- let resp = execute_command( &json!({ "id": "30", "action": "ischecked", "selector": "#aria" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["checked"], false); let resp = execute_command( &json!({ "id": "31", "action": "check", "selector": "#aria" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "32", "action": "ischecked", "selector": "#aria" }), &mut state, ) .await; assert_success(&resp); assert_eq!( get_data(&resp)["checked"], true, "ARIA checkbox should be checked after check action" ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Issue #841 – snapshot -C and screenshot --annotate must not hang over WSS // --------------------------------------------------------------------------- /// Verifies that `snapshot -C` (cursor-interactive mode) detects elements with /// cursor:pointer / onclick / tabindex, produces the correct v0.19.0-compatible /// output format, deduplicates against the ARIA tree, and completes in bounded /// time (no sequential CDP round-trip explosion). #[tokio::test] #[ignore] async fn e2e_snapshot_cursor_interactive() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Page with: // - ", "
ClickDiv
", "
FocusDiv
", "PointerSpan", "
InheritChild
", "", ); let resp = execute_command( &json!({ "id": "2", "action": "setcontent", "html": html }), &mut state, ) .await; assert_success(&resp); // snapshot -i -C: interactive tree + cursor section let start = std::time::Instant::now(); let resp = execute_command( &json!({ "id": "3", "action": "snapshot", "interactive": true, "cursor": true }), &mut state, ) .await; let elapsed = start.elapsed(); assert_success(&resp); let snapshot = get_data(&resp)["snapshot"].as_str().unwrap(); // v0.19.0 output format: role + hints assert!( snapshot.contains("clickable") && snapshot.contains("[cursor:pointer"), "Expected v0.19.0-format cursor output with hints:\n{}", snapshot, ); // Role differentiation: tabindex-only → focusable assert!( snapshot.contains("focusable") && snapshot.contains("[tabindex]"), "Expected focusable role for tabindex-only element:\n{}", snapshot, ); // Text dedup: "Link" and "Btn" are in the ARIA tree, so must NOT suffix // with cursor-interactive info. Verify line by line. for line in snapshot.lines() { assert!( !(line.contains("\"Link\"") && (line.contains("clickable") || line.contains("focusable") || line.contains("editable"))), "Standard element should not have cursor-interactive info:\n{}", line ); assert!( !(line.contains("\"Btn\"") && (line.contains("clickable") || line.contains("focusable") || line.contains("editable"))), "Standard ", i)); } html.push_str(""); let resp = execute_command( &json!({ "id": "2", "action": "setcontent", "html": html }), &mut state, ) .await; assert_success(&resp); let start = std::time::Instant::now(); let resp = execute_command( &json!({ "id": "3", "action": "screenshot", "annotate": true }), &mut state, ) .await; let elapsed = start.elapsed(); assert_success(&resp); let annotations = get_data(&resp)["annotations"] .as_array() .expect("Annotated screenshot should return annotations"); assert!( annotations.len() >= 50, "Expected at least 50 annotations, got {}", annotations.len(), ); // Must complete quickly (< 10s), not hit the 30s CDP timeout assert!( elapsed.as_secs() < 10, "screenshot --annotate with 50 elements took {:?}, expected < 10s (Issue #841)", elapsed, ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } /// Verifies `snapshot -C` with many cursor-interactive elements completes in /// bounded time. Direct regression test for Issue #841's root cause: N×2 /// sequential CDP round-trips per cursor-interactive element. #[tokio::test] #[ignore] async fn e2e_snapshot_cursor_many_elements() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // 100 cursor-interactive divs: old code = 200 sequential CDP calls, // at 200ms WSS latency = 40s timeout. New code must finish in seconds. let mut html = String::from(""); for i in 1..=100 { html.push_str(&format!( "
Item {}
", i, )); } html.push_str(""); let resp = execute_command( &json!({ "id": "2", "action": "setcontent", "html": html }), &mut state, ) .await; assert_success(&resp); let start = std::time::Instant::now(); let resp = execute_command( &json!({ "id": "3", "action": "snapshot", "interactive": true, "cursor": true }), &mut state, ) .await; let elapsed = start.elapsed(); assert_success(&resp); let snapshot = get_data(&resp)["snapshot"].as_str().unwrap(); // All 100 items should appear assert!( snapshot.contains("Item 1") && snapshot.contains("Item 100"), "Expected all 100 cursor-interactive items in output", ); // All should have v0.19.0-format hints assert!( snapshot.contains("[cursor:pointer, onclick]"), "Expected v0.19.0-format hints", ); // Must complete quickly assert!( elapsed.as_secs() < 10, "snapshot -C with 100 cursor elements took {:?}, expected < 10s (Issue #841)", elapsed, ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); }