//! 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 base64::{engine::general_purpose::STANDARD, Engine}; use futures_util::StreamExt; use serde_json::{json, Value}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use crate::test_utils::EnvGuard; 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") } fn native_test_fixture_html(name: &str) -> &'static str { match name { "drag_probe" => include_str!("test_fixtures/drag_probe.html"), "html5_drag_probe" => include_str!("test_fixtures/html5_drag_probe.html"), "pointer_capture_probe" => include_str!("test_fixtures/pointer_capture_probe.html"), "upload_probe" => include_str!("test_fixtures/upload_probe.html"), _ => panic!("Unknown native test fixture: {}", name), } } fn native_test_fixture_url(name: &str) -> String { format!( "data:text/html;base64,{}", STANDARD.encode(native_test_fixture_html(name)) ) } async fn create_storage_state_with_cookie(path: &str, cookie_name: &str, cookie_value: &str) { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true, "args": ["--no-sandbox", "--disable-dev-shm-usage"] }), &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": cookie_name, "value": cookie_value, "domain": ".example.com", "path": "/", "expires": 2000000000 }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "4", "action": "state_save", "path": path }), &mut state, ) .await; assert_success(&resp); let resp = execute_command(&json!({ "id": "5", "action": "close" }), &mut state).await; assert_success(&resp); } async fn send_raw_http_request(port: u64, request: &str) -> String { let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")) .await .expect("HTTP client should connect to stream server"); stream .write_all(request.as_bytes()) .await .expect("HTTP request should be written"); stream .shutdown() .await .expect("HTTP client write side should shut down"); let mut response = Vec::new(); stream .read_to_end(&mut response) .await .expect("HTTP response should be read"); String::from_utf8(response).expect("HTTP response should be utf-8") } #[cfg(unix)] async fn spawn_fake_daemon_socket( socket_dir: &std::path::Path, session_name: &str, ) -> tokio::sync::oneshot::Receiver { use tokio::io::AsyncBufReadExt; let socket_path = socket_dir.join(format!("{session_name}.sock")); let _ = std::fs::remove_file(&socket_path); let listener = tokio::net::UnixListener::bind(&socket_path).expect("fake daemon socket should bind"); let (tx, rx) = tokio::sync::oneshot::channel(); tokio::spawn(async move { let Ok((stream, _)) = listener.accept().await else { return; }; let mut reader = tokio::io::BufReader::new(stream); let mut command = String::new(); if reader.read_line(&mut command).await.is_err() { return; } let mut stream = reader.into_inner(); let _ = stream .write_all(br#"{"success":true,"data":{"ok":true}}"#) .await; let _ = stream.write_all(b"\n").await; let _ = tx.send(command); }); rx } // --------------------------------------------------------------------------- // 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); } // --------------------------------------------------------------------------- // Runtime stream lifecycle // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_runtime_stream_enable_before_launch_attaches_and_disables() { let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]); let socket_dir = std::env::temp_dir().join(format!( "chrome-use-e2e-stream-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system clock should be after unix epoch") .as_nanos() )); std::fs::create_dir_all(&socket_dir).expect("socket dir should be created"); guard.set( "AGENT_BROWSER_SOCKET_DIR", socket_dir.to_str().expect("socket dir should be utf-8"), ); guard.set("AGENT_BROWSER_SESSION", "e2e-runtime-stream"); let mut state = DaemonState::new(); let resp = execute_command(&json!({ "id": "1", "action": "stream_status" }), &mut state).await; assert_success(&resp); assert_eq!(get_data(&resp)["enabled"], false); let resp = execute_command( &json!({ "id": "2", "action": "stream_enable", "port": 0 }), &mut state, ) .await; assert_success(&resp); let port = get_data(&resp)["port"] .as_u64() .expect("stream enable should report the bound port"); assert_eq!(get_data(&resp)["connected"], false); let stream_path = socket_dir.join("e2e-runtime-stream.stream"); assert!( stream_path.exists(), "runtime enable should create .stream metadata" ); let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}")) .await .expect("websocket client should connect to runtime stream"); let initial = tokio::time::timeout(tokio::time::Duration::from_secs(5), ws.next()) .await .expect("websocket should emit initial status") .expect("websocket should stay open") .expect("websocket message should be valid"); let initial_text = initial.into_text().expect("initial message should be text"); let initial_status: Value = serde_json::from_str(&initial_text).expect("status JSON should parse"); assert_eq!(initial_status["type"], "status"); assert_eq!(initial_status["connected"], false); let resp = execute_command( &json!({ "id": "3", "action": "navigate", "url": "data:text/html,

Runtime Stream

" }), &mut state, ) .await; assert_success(&resp); let mut observed_connected = false; let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10); while tokio::time::Instant::now() < deadline { let Some(message) = tokio::time::timeout(tokio::time::Duration::from_secs(2), ws.next()) .await .expect("websocket should emit status after browser launch") else { continue; }; let message = message.expect("websocket message should be valid"); if !message.is_text() { continue; } let parsed: Value = serde_json::from_str(message.to_text().expect("text message should be readable")) .expect("runtime stream payload should be valid JSON"); if parsed.get("type") == Some(&json!("status")) && parsed.get("connected") == Some(&json!(true)) { observed_connected = true; break; } } assert!( observed_connected, "runtime stream should report connected=true after browser launch" ); let resp = execute_command( &json!({ "id": "4", "action": "stream_disable" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["disabled"], true); assert!( !stream_path.exists(), "stream disable should remove .stream metadata" ); let close_message = tokio::time::timeout(tokio::time::Duration::from_secs(5), ws.next()) .await .expect("websocket should close after disable"); assert!( close_message.is_none() || close_message.expect("ws result should exist").is_ok(), "websocket should shut down cleanly when the runtime stream is disabled" ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); let _ = std::fs::remove_dir_all(&socket_dir); } #[cfg(unix)] #[tokio::test] #[ignore] async fn e2e_stream_command_requires_same_origin_before_daemon_relay() { let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]); let temp_parent = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("target") .join("t"); std::fs::create_dir_all(&temp_parent).expect("socket temp parent should be created"); let socket_dir = tempfile::Builder::new() .prefix("ab-e2e-") .tempdir_in(temp_parent) .expect("socket dir should be created"); guard.set( "AGENT_BROWSER_SOCKET_DIR", socket_dir .path() .to_str() .expect("socket dir should be utf-8"), ); guard.set("AGENT_BROWSER_SESSION", "x"); let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "stream_enable", "port": 0 }), &mut state, ) .await; assert_success(&resp); let port = get_data(&resp)["port"] .as_u64() .expect("stream enable should report the bound port"); let mut daemon_command = spawn_fake_daemon_socket(socket_dir.path(), "x").await; let body = r#"{"action":"tabs"}"#; let cross_origin_request = format!( "POST /api/command HTTP/1.1\r\nHost: localhost:{port}\r\nOrigin: https://evil.example\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", body.len(), body ); let response = send_raw_http_request(port, &cross_origin_request).await; assert!( response.starts_with("HTTP/1.1 403 Forbidden"), "unexpected cross-origin response: {response}" ); assert!( !response.contains("Access-Control-Allow-Origin: *"), "forbidden command response exposed wildcard CORS: {response}" ); assert!( tokio::time::timeout(std::time::Duration::from_millis(100), &mut daemon_command) .await .is_err(), "cross-origin command request reached daemon relay" ); let same_origin_request = format!( "POST /api/command HTTP/1.1\r\nHost: localhost:{port}\r\nOrigin: http://localhost:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", body.len(), body ); let response = send_raw_http_request(port, &same_origin_request).await; assert!( response.starts_with("HTTP/1.1 200 OK"), "unexpected same-origin response: {response}" ); assert!( response.contains(&format!( "Access-Control-Allow-Origin: http://localhost:{port}" )), "same-origin command response did not reflect origin: {response}" ); assert!( !response.contains("Access-Control-Allow-Origin: *"), "same-origin command response exposed wildcard CORS: {response}" ); let relayed = tokio::time::timeout(std::time::Duration::from_secs(1), daemon_command) .await .expect("same-origin request should reach fake daemon") .expect("fake daemon should return relayed command"); assert!(relayed.contains(r#""action":"tabs""#), "{relayed}"); let resp = execute_command( &json!({ "id": "2", "action": "stream_disable" }), &mut state, ) .await; assert_success(&resp); } // --------------------------------------------------------------------------- // 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("chrome-use-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 with tabId 1 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); assert_eq!(tabs[0]["tabId"], "t1", "First tab should have tabId t1"); // 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)["tabId"], "t2", "New tab should have tabId t2" ); assert_eq!(get_data(&resp)["total"], 2); // Tab list should show 2 tabs with distinct, incrementing tabIds 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); assert_eq!(tabs[0]["tabId"], "t1", "First tab should keep tabId t1"); assert_eq!(tabs[1]["tabId"], "t2", "Second tab should have tabId t2"); // Switch to first tab let resp = execute_command( &json!({ "id": "6", "action": "tab_switch", "tabId": "t1" }), &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", "tabId": "t2" }), &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); } #[tokio::test] #[ignore] async fn e2e_tab_ids_not_reused() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // First tab gets tabId 1 let resp = execute_command(&json!({ "id": "2", "action": "tab_list" }), &mut state).await; assert_success(&resp); let tabs = get_data(&resp)["tabs"].as_array().unwrap(); assert_eq!(tabs[0]["tabId"], "t1"); // Open tab 2 and tab 3 let resp = execute_command( &json!({ "id": "3", "action": "tab_new", "url": "data:text/html,

Tab 2

" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["tabId"], "t2"); let resp = execute_command( &json!({ "id": "4", "action": "tab_new", "url": "data:text/html,

Tab 3

" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["tabId"], "t3"); // Close tab 2 let resp = execute_command( &json!({ "id": "5", "action": "tab_close", "tabId": "t2" }), &mut state, ) .await; assert_success(&resp); // Open a new tab — should get tabId 4, NOT 2 let resp = execute_command( &json!({ "id": "6", "action": "tab_new", "url": "data:text/html,

Tab 4

" }), &mut state, ) .await; assert_success(&resp); assert_eq!( get_data(&resp)["tabId"], "t4", "Tab IDs must not be reused after closing" ); // Verify final state: tabs t1, t3, t4 let resp = execute_command(&json!({ "id": "7", "action": "tab_list" }), &mut state).await; assert_success(&resp); let tabs = get_data(&resp)["tabs"].as_array().unwrap(); assert_eq!(tabs.len(), 3); let ids: Vec = tabs .iter() .map(|t| t["tabId"].as_str().unwrap().to_string()) .collect(); assert_eq!(ids, vec!["t1", "t3", "t4"]); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } /// `tab_close` with an explicit `tabId` must close that tab regardless of /// whether it's active, and leave the remaining tab active without leaking /// per-tab state (refs, iframe sessions, frame id) from the closed tab. #[tokio::test] #[ignore] async fn e2e_tab_close_with_tab_id_closes_active_tab() { 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,A" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "3", "action": "tab_new", "url": "data:text/html,B" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "4", "action": "tab_close", "tabId": "t2" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command(&json!({ "id": "5", "action": "title" }), &mut state).await; assert_success(&resp); assert_eq!(get_data(&resp)["title"], "A"); assert!(state.ref_map.get("e1").is_none()); assert!(state.iframe_sessions.is_empty()); assert!(state.active_frame_id.is_none()); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } /// Tabs can be opened with a user-assigned label and then addressed by that /// label anywhere a `t` id is accepted (switch, close, and JSON `tabId` /// on `tab_switch` / `tab_close`). Labels are the agent-friendly way to /// write multi-tab workflows without memorizing ids. #[tokio::test] #[ignore] async fn e2e_tab_new_with_label_can_be_switched_and_closed() { 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,Home" }), &mut state, ) .await; assert_success(&resp); // Open a labeled tab and verify the response echoes the label and a // `t` style tabId. let resp = execute_command( &json!({ "id": "3", "action": "tab_new", "url": "data:text/html,Docs", "label": "docs", }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["tabId"], "t2"); assert_eq!(get_data(&resp)["label"], "docs"); // tab_list exposes the label alongside the id. let resp = execute_command(&json!({ "id": "4", "action": "tab_list" }), &mut state).await; assert_success(&resp); let tabs = get_data(&resp)["tabs"].as_array().unwrap(); let docs = tabs .iter() .find(|t| t["tabId"] == "t2") .expect("docs tab should be present"); assert_eq!(docs["label"], "docs"); // tab_switch accepts the label. let resp = execute_command( &json!({ "id": "5", "action": "tab_switch", "tabId": "t1" }), &mut state, ) .await; assert_success(&resp); assert_eq!(state.browser.as_ref().unwrap().active_tab_id(), Some(1)); let resp = execute_command( &json!({ "id": "6", "action": "tab_switch", "tabId": "docs" }), &mut state, ) .await; assert_success(&resp); assert_eq!(state.browser.as_ref().unwrap().active_tab_id(), Some(2)); // Once switched, the active tab is the labeled one and normal commands // work against it. let resp = execute_command(&json!({ "id": "7", "action": "title" }), &mut state).await; assert_success(&resp); assert_eq!(get_data(&resp)["title"], "Docs"); // tab_close accepts the label. let resp = execute_command( &json!({ "id": "8", "action": "tab_close", "tabId": "docs" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["label"], "docs"); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } /// Duplicate labels must be rejected so agents can treat a label as a unique /// handle. The first tab keeps the label; the second tab's creation errors. #[tokio::test] #[ignore] async fn e2e_tab_new_with_duplicate_label_errors() { 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": "tab_new", "url": "about:blank", "label": "docs" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "3", "action": "tab_new", "url": "about:blank", "label": "docs" }), &mut state, ) .await; assert_eq!( resp.get("success").and_then(|v| v.as_bool()), Some(false), "duplicate label should error: {}", serde_json::to_string_pretty(&resp).unwrap_or_default() ); let err = resp.get("error").and_then(|v| v.as_str()).unwrap_or(""); assert!( err.contains("already used"), "error should explain the collision: {}", err ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } /// Positional integers passed as `tabId` on tab-switch / tab-close must be /// rejected by the daemon-layer parser, not silently coerced. The error /// should teach the user the correct form (`t`). #[tokio::test] #[ignore] async fn e2e_tab_switch_rejects_bare_integer() { 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": "tab_switch", "tabId": "2" }), &mut state, ) .await; assert_eq!( resp.get("success").and_then(|v| v.as_bool()), Some(false), "bare integer tabId on tab_switch should error: {}", serde_json::to_string_pretty(&resp).unwrap_or_default() ); let err = resp.get("error").and_then(|v| v.as_str()).unwrap_or(""); assert!( err.contains("t2") && err.contains("positional integers"), "error should teach `t` convention: {}", err ); 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); } // --------------------------------------------------------------------------- // Same-document navigation regression test // --------------------------------------------------------------------------- // // Chrome may perform a same-document navigation when it determines the target // URL is the same document as the current page (ignoring fragment). This // causes Page.loadEventFired to not fire, making wait_for_lifecycle // hang forever waiting for an event that never comes. // // The fix checks loader_id in the Page.navigate response - if None, // it's a same-document navigation and we skip waiting for lifecycle events. #[tokio::test] #[ignore] async fn e2e_navigate_same_url_twice_should_not_hang() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Navigate to about:blank first to start from a known state let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "about:blank" }), &mut state, ) .await; assert_success(&resp); // Create a simple HTML page that changes its own URL via history.pushState // This simulates SPA routing behavior which triggers same-document navigation let base_page = "data:text/html,

Test

"; // Navigate to the page (first time) let resp = execute_command( &json!({ "id": "3", "action": "navigate", "url": base_page }), &mut state, ) .await; assert_success(&resp); // Verify URL changed due to pushState let resp = execute_command(&json!({ "id": "4", "action": "url" }), &mut state).await; assert_success(&resp); let url_after_push = get_data(&resp)["url"].as_str().unwrap(); // URL should have changed to include /#/home due to pushState assert!( url_after_push.contains("/%23/home") || url_after_push.contains("/#/home"), "URL should have changed via pushState, got: {}", url_after_push ); // Navigate to the SAME base URL again // Without fix: Chrome may do same-document nav, wait_for_lifecycle hangs // With fix: We detect loader_id is None and skip waiting let start = std::time::Instant::now(); let resp = execute_command( &json!({ "id": "5", "action": "navigate", "url": base_page }), &mut state, ) .await; let elapsed = start.elapsed().as_secs(); // Should complete quickly (< 5 seconds) without hanging // Without fix, this times out after 25 seconds (default_timeout_ms) assert!( elapsed < 5, "Second navigation should not hang, but took {}s", elapsed ); assert_success(&resp); 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); } // --------------------------------------------------------------------------- // Raw mouse regressions // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_mouse_down_move_up_preserves_drag_state() { 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": native_test_fixture_url("drag_probe") }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "3", "action": "evaluate", "script": r#"(() => { const rect = document.getElementById('target').getBoundingClientRect(); return { left: Math.round(rect.left), top: Math.round(rect.top), x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }; })()"# }), &mut state, ) .await; assert_success(&resp); let start = &get_data(&resp)["result"]; let initial_left = start["left"] .as_i64() .expect("target left should be numeric"); let initial_top = start["top"].as_i64().expect("target top should be numeric"); let start_x = start["x"].as_i64().expect("target x should be numeric"); let start_y = start["y"].as_i64().expect("target y should be numeric"); let end_x = start_x + 80; let end_y = start_y + 60; let resp = execute_command( &json!({ "id": "4", "action": "mousemove", "x": start_x, "y": start_y }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "5", "action": "mousedown", "button": "left" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "6", "action": "mousemove", "x": end_x, "y": end_y }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "7", "action": "mouseup", "button": "left" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "8", "action": "evaluate", "script": "window.__dragProbe" }), &mut state, ) .await; assert_success(&resp); let probe = &get_data(&resp)["result"]; assert_eq!(probe["finalLeft"].as_i64(), Some(initial_left + 80)); assert_eq!(probe["finalTop"].as_i64(), Some(initial_top + 60)); let events = probe["events"] .as_array() .expect("drag probe should expose events"); assert!( events.iter().any(|event| { event["type"] == "mousedown" && event["x"].as_f64() == Some(start_x as f64) && event["y"].as_f64() == Some(start_y as f64) && event["buttons"].as_i64() == Some(1) }), "Expected a non-zero mousedown event in drag probe" ); assert!( events.iter().any(|event| { event["type"] == "mousemove" && event["x"].as_f64() == Some(end_x as f64) && event["y"].as_f64() == Some(end_y as f64) && event["buttons"].as_i64() == Some(1) }), "Expected a drag mousemove with the button still pressed" ); assert!( events.iter().any(|event| { event["type"] == "mouseup" && event["x"].as_f64() == Some(end_x as f64) && event["y"].as_f64() == Some(end_y as f64) && event["buttons"].as_i64() == Some(0) }), "Expected mouseup at the last drag position" ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } #[tokio::test] #[ignore] async fn e2e_mouse_drag_reaches_pointer_capture_target() { 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": native_test_fixture_url("pointer_capture_probe") }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "3", "action": "evaluate", "script": r#"(() => { const rect = document.getElementById('handle').getBoundingClientRect(); return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }; })()"# }), &mut state, ) .await; assert_success(&resp); let start = &get_data(&resp)["result"]; let start_x = start["x"].as_i64().expect("handle x should be numeric"); let start_y = start["y"].as_i64().expect("handle y should be numeric"); let end_x = start_x + 80; let end_y = start_y + 60; let resp = execute_command( &json!({ "id": "4", "action": "mousemove", "x": start_x, "y": start_y }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "5", "action": "mousedown", "button": "left" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "6", "action": "mousemove", "x": end_x, "y": end_y }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "7", "action": "mouseup", "button": "left" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "8", "action": "evaluate", "script": "window.__pointerCaptureProbe" }), &mut state, ) .await; assert_success(&resp); let probe = &get_data(&resp)["result"]; assert_eq!(probe["moved"].as_bool(), Some(true)); let events = probe["events"] .as_array() .expect("pointer capture probe should expose events"); assert!( events.iter().any(|event| { event["type"] == "pointermove" && event["phase"] == "drag" && event["hasCapture"].as_bool() == Some(true) && event["x"].as_f64() == Some(end_x as f64) && event["y"].as_f64() == Some(end_y as f64) }), "Expected pointermove with capture during the drag" ); assert!( events.iter().any(|event| { event["type"] == "pointerup" && event["phase"] == "up" && event["hadCapture"].as_bool() == Some(true) }), "Expected pointerup to observe an active pointer capture" ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } #[tokio::test] #[ignore] async fn e2e_drag_action_sends_buttons_during_move() { 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": native_test_fixture_url("html5_drag_probe") }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "3", "action": "drag", "source": "#source", "target": "#dest" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["dragged"].as_bool(), Some(true)); let resp = execute_command( &json!({ "id": "4", "action": "evaluate", "script": "window.__html5DragProbe" }), &mut state, ) .await; assert_success(&resp); let probe = &get_data(&resp)["result"]; let events = probe["events"] .as_array() .expect("html5 drag probe should expose events"); // The mousemove events emitted while the button is held should carry // buttons == 1 so the browser recognises the gesture as a drag. assert!( events .iter() .any(|event| { event["type"] == "mousemove" && event["buttons"].as_i64() == Some(1) }), "Expected at least one mousemove with buttons == 1 during drag" ); // dragstart must fire on the source element. assert!( events.iter().any(|event| event["type"] == "dragstart"), "Expected dragstart to fire on the source element" ); 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("chrome-use-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); } // --------------------------------------------------------------------------- // Cross-domain state save (issue #1060) // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_save_state_cross_domain() { let mut state = DaemonState::new(); // Launch let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Navigate to domain A and set cookie + localStorage. Use example.org (a // stable IANA-reserved domain, like example.com below) rather than an // external service such as httpbin.org — cookie/localStorage are set // client-side via CDP, so the only requirement is that the page loads // reliably. A flaky external domain made this test intermittently fail in CI. let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": "https://example.org/" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "3", "action": "cookies_set", "name": "domainA_cookie", "value": "from_example_org" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "4", "action": "storage_set", "type": "local", "key": "domainA_key", "value": "domainA_val" }), &mut state, ) .await; assert_success(&resp); // Navigate to domain B and set cookie + localStorage let resp = execute_command( &json!({ "id": "5", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "6", "action": "cookies_set", "name": "domainB_cookie", "value": "from_example" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "7", "action": "storage_set", "type": "local", "key": "domainB_key", "value": "domainB_val" }), &mut state, ) .await; assert_success(&resp); // Save state (currently on example.com) let tmp_state = std::env::temp_dir() .join("chrome-use-e2e-cross-domain-state.json") .to_string_lossy() .to_string(); let resp = execute_command( &json!({ "id": "8", "action": "state_save", "path": &tmp_state }), &mut state, ) .await; assert_success(&resp); // Read and verify saved state let saved = std::fs::read_to_string(&tmp_state).expect("State file should exist"); let state_data: serde_json::Value = serde_json::from_str(&saved).unwrap(); // Verify BOTH domain cookies are present let cookies = state_data["cookies"].as_array().unwrap(); let has_domain_a = cookies.iter().any(|c| c["name"] == "domainA_cookie"); let has_domain_b = cookies.iter().any(|c| c["name"] == "domainB_cookie"); assert!( has_domain_a, "Should include cross-domain cookie from example.org: {:?}", cookies ); assert!( has_domain_b, "Should include cookie from example.com: {:?}", cookies ); // Verify BOTH origins' localStorage are present let origins = state_data["origins"].as_array().unwrap(); // Match full hostnames so the two example.* origins don't alias each other. let has_origin_a = origins.iter().any(|o| { o["origin"] .as_str() .is_some_and(|s| s.contains("example.org")) && o["localStorage"] .as_array() .is_some_and(|ls| ls.iter().any(|e| e["name"] == "domainA_key")) }); let has_origin_b = origins.iter().any(|o| { o["origin"] .as_str() .is_some_and(|s| s.contains("example.com")) && o["localStorage"] .as_array() .is_some_and(|ls| ls.iter().any(|e| e["name"] == "domainB_key")) }); assert!( has_origin_a, "Should include localStorage from example.org origin: {:?}", origins ); assert!( has_origin_b, "Should include localStorage from example.com origin: {:?}", origins ); // 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(); // Set domain filter BEFORE launch so Fetch.enable is called during // launch and the background fetch handler intercepts from the start. { let mut df = state.domain_filter.write().await; *df = Some(super::network::DomainFilter::new("example.com")); } let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // 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 ); // Verify that in-page fetch to a blocked domain is also blocked by // the Fetch interception layer (not just the navigate-level check). // First navigate to the allowed domain. let resp = execute_command( &json!({ "id": "4", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); // Attempt a cross-origin fetch to a blocked domain from the page. let resp = execute_command( &json!({ "id": "5", "action": "evaluate", "script": "fetch('https://blocked.com/data').then(() => 'ok').catch(e => 'blocked:' + e.message)", "await": true, }), &mut state, ) .await; assert_success(&resp); let result = get_data(&resp)["result"].as_str().unwrap_or(""); assert!( result.starts_with("blocked:"), "Fetch to blocked domain should fail, got: {}", result, ); 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!("chrome-use-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 // (PS: -C is deprecated, cursor-interactive elements are referred by default now) // --------------------------------------------------------------------------- /// Verifies that `snapshot` 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: interactive tree let start = std::time::Instant::now(); let resp = execute_command( &json!({ "id": "3", "action": "snapshot", "interactive": 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` 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 }), &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 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); } /// Test that InlineTextBox nodes are filtered from snapshot output while preserving /// the actual text content from parent elements. #[tokio::test] #[ignore] async fn e2e_snapshot_continuous_static_text() { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Simple HTML with text content that would generate InlineTextBox nodes and sperate to multiple StaticText nodes let html = "data:text/html,
Hello World
"; let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": html }), &mut state, ) .await; assert_success(&resp); // Take snapshot to capture full output and verify InlineTextBox filtering and StaticText aggregation let start = std::time::Instant::now(); let resp = execute_command(&json!({ "id": "3", "action": "snapshot" }), &mut state).await; assert_success(&resp); let elapsed = start.elapsed(); let snapshot_output = get_data(&resp)["snapshot"].as_str().unwrap(); // Verify that InlineTextBox does not appear in the output assert!( !snapshot_output.contains("InlineTextBox"), "Snapshot output should not contain InlineTextBox: {}", snapshot_output ); // Verify that the actual text content is preserved assert!( snapshot_output.contains("Hello World"), "Snapshot should contain 'Hello World': {}", snapshot_output ); // Must complete quickly assert!( elapsed.as_secs() < 5, "snapshot with InlineTextBox filtering took {:?}, expected < 5s", elapsed, ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Helper: tiny HTTP server that echoes request headers as JSON // --------------------------------------------------------------------------- /// Starts a TCP listener on localhost:0 and spawns a task that accepts /// connections, reads the HTTP request, and responds with a JSON body /// containing all received request headers. Returns the server's base URL. async fn start_echo_server() -> (String, tokio::task::JoinHandle<()>) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let base_url = format!("http://127.0.0.1:{}", port); let handle = tokio::spawn(async move { // Serve up to 20 requests then exit (enough for all tests). for _ in 0..20 { let Ok((mut stream, _)) = listener.accept().await else { break; }; tokio::spawn(async move { let mut buf = vec![0u8; 8192]; let n = stream.read(&mut buf).await.unwrap_or(0); let request = String::from_utf8_lossy(&buf[..n]); // Parse headers from the HTTP request. let mut headers = serde_json::Map::new(); for line in request.lines().skip(1) { if line.is_empty() { break; } if let Some((key, value)) = line.split_once(": ") { headers.insert(key.to_string(), Value::String(value.to_string())); } } let body = serde_json::to_string(&json!({ "headers": headers })).unwrap(); let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ Access-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\ Connection: close\r\n\r\n{}", body.len(), body, ); let _ = stream.write_all(response.as_bytes()).await; let _ = stream.flush().await; }); } }); (base_url, handle) } /// Starts a tiny HTTP server that serves a delayed-render login form. /// /// The page continuously fetches `/ping` so `networkidle` is hard to reach, /// while the login form itself appears after `render_delay_ms`. async fn start_delayed_login_server( render_delay_ms: u64, ping_interval_ms: u64, ) -> (String, tokio::task::JoinHandle<()>) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let base_url = format!("http://127.0.0.1:{}", port); let handle = tokio::spawn(async move { // Serve enough requests for navigation + many background /ping calls. for _ in 0..1000 { let Ok((mut stream, _)) = listener.accept().await else { break; }; tokio::spawn(async move { let mut buf = vec![0u8; 8192]; let n = stream.read(&mut buf).await.unwrap_or(0); let request = String::from_utf8_lossy(&buf[..n]); let request_line = request.lines().next().unwrap_or_default(); let path = request_line.split_whitespace().nth(1).unwrap_or("/"); let (status, content_type, body) = if path.starts_with("/ping") { ("204 No Content", "text/plain", String::new()) } else { let html = format!( r#" Delayed Login
loading...
"#, ); ("200 OK", "text/html", html) }; let response = format!( "HTTP/1.1 {}\r\nContent-Type: {}\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", status, content_type, body.len(), body, ); let _ = stream.write_all(response.as_bytes()).await; let _ = stream.flush().await; }); } }); (base_url, handle) } #[tokio::test] #[ignore] async fn e2e_auth_login_waits_for_delayed_spa_form_render() { let (base_url, _server) = start_delayed_login_server(800, 100).await; let mut state = DaemonState::new(); let profile_name = format!( "e2e-auth-login-spa-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_else(|_| std::time::Duration::from_secs(0)) .as_millis() ); let launch = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&launch); let save = execute_command( &json!({ "id": "2", "action": "auth_save", "name": profile_name.clone(), "url": format!("{}/login", base_url), "username": "user@example.com", "password": "super-secret", }), &mut state, ) .await; assert_success(&save); let login = execute_command( &json!({ "id": "3", "action": "auth_login", "name": profile_name.clone() }), &mut state, ) .await; assert_success(&login); assert_eq!(get_data(&login)["loggedIn"], true); let verify = execute_command( &json!({ "id": "4", "action": "evaluate", "script": "({ user: document.querySelector('input[type=email]')?.value ?? '', pass: document.querySelector('input[type=password]')?.value ?? '', search: document.querySelector('#search')?.value ?? '', submitted: !!window.__submitted })", }), &mut state, ) .await; assert_success(&verify); let result = &get_data(&verify)["result"]; assert_eq!(result["user"], "user@example.com"); assert_eq!(result["pass"], "super-secret"); assert_eq!(result["search"], ""); assert_eq!(result["submitted"], true); let _ = execute_command( &json!({ "id": "5", "action": "auth_delete", "name": profile_name }), &mut state, ) .await; let close = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&close); } // --------------------------------------------------------------------------- // Origin-scoped --headers tests // --------------------------------------------------------------------------- /// Headers passed via --headers on open persist for subsequent same-origin /// navigations (the core regression from the Rust rewrite). #[tokio::test] #[ignore] async fn e2e_headers_persist_same_origin_navigation() { let (base_url, _server) = start_echo_server().await; let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Navigate with --headers. let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": format!("{}/first", base_url), "headers": { "X-Test": "scoped" }, }), &mut state, ) .await; assert_success(&resp); // Navigate to the same origin WITHOUT --headers. let resp = execute_command( &json!({ "id": "3", "action": "navigate", "url": format!("{}/second", base_url), }), &mut state, ) .await; assert_success(&resp); // The page body is the echo JSON. Read it via evaluate. let resp = execute_command( &json!({ "id": "4", "action": "evaluate", "script": "JSON.parse(document.body.innerText)", }), &mut state, ) .await; assert_success(&resp); let result = &get_data(&resp)["result"]; assert_eq!( result["headers"]["X-Test"], "scoped", "X-Test header should persist on same-origin navigation without --headers" ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } /// Headers passed via --headers on open persist for in-page fetch/XHR to /// the same origin. #[tokio::test] #[ignore] async fn e2e_headers_persist_same_origin_fetch() { let (base_url, _server) = start_echo_server().await; let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Navigate with --headers. let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": format!("{}/page", base_url), "headers": { "X-Test": "fetched" }, }), &mut state, ) .await; assert_success(&resp); // In-page fetch to the same origin (relative URL). let resp = execute_command( &json!({ "id": "3", "action": "evaluate", "script": "fetch('/echo').then(r => r.json())", "await": true, }), &mut state, ) .await; assert_success(&resp); let result = &get_data(&resp)["result"]; assert_eq!( result["headers"]["X-Test"], "fetched", "X-Test header should be present on in-page fetch to same origin" ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } /// Headers set via --headers do NOT leak to a different origin. #[tokio::test] #[ignore] async fn e2e_headers_do_not_leak_cross_origin() { let (server_a, _ha) = start_echo_server().await; let (server_b, _hb) = start_echo_server().await; let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Navigate to server A with --headers. let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": format!("{}/page", server_a), "headers": { "X-Secret": "a-only" }, }), &mut state, ) .await; assert_success(&resp); // Navigate to server B (different origin) without --headers. let resp = execute_command( &json!({ "id": "3", "action": "navigate", "url": format!("{}/page", server_b), }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "4", "action": "evaluate", "script": "JSON.parse(document.body.innerText)", }), &mut state, ) .await; assert_success(&resp); let result = &get_data(&resp)["result"]; assert!( result["headers"].get("X-Secret").is_none(), "X-Secret header must NOT leak to a different origin, got: {}", result["headers"], ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } /// In-page fetch to a cross-origin URL must NOT include the origin-scoped /// headers (sub-resource isolation). #[tokio::test] #[ignore] async fn e2e_headers_do_not_leak_cross_origin_fetch() { let (server_a, _ha) = start_echo_server().await; let (server_b, _hb) = start_echo_server().await; let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Navigate to server A with --headers. let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": format!("{}/page", server_a), "headers": { "X-Secret": "a-only" }, }), &mut state, ) .await; assert_success(&resp); // Fetch from the page to server B (cross-origin sub-resource). let resp = execute_command( &json!({ "id": "3", "action": "evaluate", "script": format!("fetch('{}/echo').then(r => r.json())", server_b), "await": true, }), &mut state, ) .await; assert_success(&resp); let result = &get_data(&resp)["result"]; assert!( result["headers"].get("X-Secret").is_none(), "X-Secret header must NOT leak to cross-origin fetch, got: {}", result["headers"], ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } /// `set headers` (global headers via the headers action) must not be /// regressed — they should persist across navigations without being /// cleared by the origin-scoped header logic. #[tokio::test] #[ignore] async fn e2e_set_headers_not_regressed() { let (base_url, _server) = start_echo_server().await; let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Set global headers via the `headers` action (not --headers on navigate). let resp = execute_command( &json!({ "id": "2", "action": "headers", "headers": { "X-Global": "everywhere" }, }), &mut state, ) .await; assert_success(&resp); // Navigate — global headers should be present. let resp = execute_command( &json!({ "id": "3", "action": "navigate", "url": format!("{}/page", base_url), }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "4", "action": "evaluate", "script": "JSON.parse(document.body.innerText)", }), &mut state, ) .await; assert_success(&resp); let result = &get_data(&resp)["result"]; assert_eq!( result["headers"]["X-Global"], "everywhere", "Global headers set via `set headers` must persist across navigations" ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } /// Multiple origins each get their own independent headers. #[tokio::test] #[ignore] async fn e2e_headers_multiple_origins_independent() { let (server_a, _ha) = start_echo_server().await; let (server_b, _hb) = start_echo_server().await; let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Set headers for origin A. let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": format!("{}/page", server_a), "headers": { "X-From": "alpha" }, }), &mut state, ) .await; assert_success(&resp); // Set different headers for origin B. let resp = execute_command( &json!({ "id": "3", "action": "navigate", "url": format!("{}/page", server_b), "headers": { "X-From": "beta" }, }), &mut state, ) .await; assert_success(&resp); // Verify B got its own header. let resp = execute_command( &json!({ "id": "4", "action": "evaluate", "script": "JSON.parse(document.body.innerText)" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"]["headers"]["X-From"], "beta"); // Navigate back to A — should get A's header, not B's. let resp = execute_command( &json!({ "id": "5", "action": "navigate", "url": format!("{}/check", server_a) }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "6", "action": "evaluate", "script": "JSON.parse(document.body.innerText)" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"]["headers"]["X-From"], "alpha"); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } /// Headers persist when navigating away to a different origin and back. #[tokio::test] #[ignore] async fn e2e_headers_persist_after_roundtrip() { let (server_a, _ha) = start_echo_server().await; let (server_b, _hb) = start_echo_server().await; let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Set headers for origin A. let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": format!("{}/page", server_a), "headers": { "X-Persist": "roundtrip" }, }), &mut state, ) .await; assert_success(&resp); // Navigate away to B (no headers). let resp = execute_command( &json!({ "id": "3", "action": "navigate", "url": format!("{}/page", server_b) }), &mut state, ) .await; assert_success(&resp); // Navigate back to A without --headers. let resp = execute_command( &json!({ "id": "4", "action": "navigate", "url": format!("{}/back", server_a) }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "5", "action": "evaluate", "script": "JSON.parse(document.body.innerText)" }), &mut state, ) .await; assert_success(&resp); assert_eq!( get_data(&resp)["result"]["headers"]["X-Persist"], "roundtrip", "Headers should persist after navigating away and back to the same origin" ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } /// Passing --headers a second time to the same origin replaces the previous headers. #[tokio::test] #[ignore] async fn e2e_headers_override_same_origin() { let (base_url, _server) = start_echo_server().await; let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Set initial headers. let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": format!("{}/first", base_url), "headers": { "X-Version": "v1" }, }), &mut state, ) .await; assert_success(&resp); // Override with new headers. let resp = execute_command( &json!({ "id": "3", "action": "navigate", "url": format!("{}/second", base_url), "headers": { "X-Version": "v2" }, }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "4", "action": "evaluate", "script": "JSON.parse(document.body.innerText)" }), &mut state, ) .await; assert_success(&resp); assert_eq!( get_data(&resp)["result"]["headers"]["X-Version"], "v2", "Second --headers should replace the first for the same origin" ); // Subsequent navigation without --headers should use v2. let resp = execute_command( &json!({ "id": "5", "action": "navigate", "url": format!("{}/third", base_url) }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "6", "action": "evaluate", "script": "JSON.parse(document.body.innerText)" }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["result"]["headers"]["X-Version"], "v2"); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } /// `set headers` (global) and `--headers` (origin-scoped) stack together. #[tokio::test] #[ignore] async fn e2e_global_and_scoped_headers_stack() { let (base_url, _server) = start_echo_server().await; let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Set global headers via `set headers`. let resp = execute_command( &json!({ "id": "2", "action": "headers", "headers": { "X-Global": "everywhere" }, }), &mut state, ) .await; assert_success(&resp); // Set origin-scoped headers via --headers. let resp = execute_command( &json!({ "id": "3", "action": "navigate", "url": format!("{}/page", base_url), "headers": { "X-Scoped": "this-origin" }, }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "4", "action": "evaluate", "script": "JSON.parse(document.body.innerText)" }), &mut state, ) .await; assert_success(&resp); let headers = &get_data(&resp)["result"]["headers"]; assert_eq!( headers["X-Global"], "everywhere", "Global header should be present alongside scoped header" ); assert_eq!( headers["X-Scoped"], "this-origin", "Scoped header should be present alongside global header" ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } /// Origin-scoped headers with different casing than the browser's original /// request headers must not produce duplicates (HTTP headers are /// case-insensitive per RFC 7230). #[tokio::test] #[ignore] async fn e2e_headers_case_insensitive_no_duplicates() { let (base_url, _server) = start_echo_server().await; let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); // Chrome sends "Accept: ..." by default on navigations. Pass "accept" // (lowercase) via --headers to verify the merge is case-insensitive // and doesn't produce a duplicate Accept header. let resp = execute_command( &json!({ "id": "2", "action": "navigate", "url": format!("{}/page", base_url), "headers": { "accept": "application/test" }, }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "3", "action": "evaluate", "script": "JSON.parse(document.body.innerText)", }), &mut state, ) .await; assert_success(&resp); let result = &get_data(&resp)["result"]["headers"]; // The echo server stores headers keyed by name as received on the wire. // If deduplication works, only our custom "accept" value should appear // (Chrome's original "Accept: text/html,..." should be suppressed). let accept_val = result .get("accept") .or_else(|| result.get("Accept")) .and_then(|v| v.as_str()) .unwrap_or(""); assert_eq!( accept_val, "application/test", "Case-insensitive merge should replace Chrome's Accept header, got headers: {}", result, ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Regression: externally opened tabs must appear in tab_list (#1037) // // When connected to Chrome (launched or via --cdp), a tab opened outside of // chrome-use (e.g. by the user or another CDP client) should be detected // and listed. Previously, chrome://newtab/ was filtered by // is_internal_chrome_target, and Target.targetInfoChanged for untracked // targets was silently ignored. // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_externally_opened_tab_detected() { 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); // Verify initial tab count let resp = execute_command(&json!({ "id": "2", "action": "tab_list" }), &mut state).await; assert_success(&resp); let initial_count = get_data(&resp)["tabs"].as_array().unwrap().len(); // Simulate an external client opening a new tab via the browser-level CDP // session (no sessionId). This mirrors what happens when a user manually // opens a tab while chrome-use is connected via --cdp. let browser = state.browser.as_ref().expect("browser should be launched"); let _: Value = browser .client .send_command( "Target.createTarget", Some(json!({ "url": "data:text/html,

External Tab

" })), None, // browser-level session ) .await .expect("Target.createTarget should succeed"); // Give Chrome a moment to fire targetCreated / targetInfoChanged events tokio::time::sleep(std::time::Duration::from_millis(500)).await; // Drain events by issuing tab_list — this triggers execute_command's // drain_cdp_events path which processes new and changed targets. 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(), initial_count + 1, "Externally opened tab should appear in tab_list, got: {:?}", tabs, ); // Verify the new tab's URL is the data URL we navigated to let new_tab = tabs.iter().find(|t| { t["url"] .as_str() .is_some_and(|u| u.starts_with("data:text/html")) }); assert!( new_tab.is_some(), "Should find the externally opened tab by URL, tabs: {:?}", tabs, ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Regression: issue #993 — launch options change must trigger relaunch // --------------------------------------------------------------------------- /// When the browser is already running and a second launch command arrives with /// different options (e.g., extensions added), the daemon must relaunch the /// browser instead of silently reusing the old one. /// /// Before the fix, `handle_launch` only checked connection type and liveness, /// so changed options like extensions were ignored and the old browser was reused. #[tokio::test] #[ignore] async fn e2e_relaunch_on_options_change() { let mut state = DaemonState::new(); // First launch — headless, no extensions. let resp = execute_command( &json!({ "id": "1", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["launched"], true); assert!( get_data(&resp).get("reused").is_none(), "first launch must not be a reuse" ); // Second launch — same options → should reuse. let resp = execute_command( &json!({ "id": "2", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); assert_eq!( get_data(&resp)["reused"], true, "identical options must reuse the browser" ); // Third launch — different options (userAgent changed) → must relaunch, not reuse. // We use userAgent instead of extensions because extensions force headed mode, // which requires a display server and fails in headless CI environments. let resp = execute_command( &json!({ "id": "3", "action": "launch", "headless": true, "userAgent": "chrome-use-test/1.0" }), &mut state, ) .await; assert_success(&resp); assert!( get_data(&resp).get("reused").is_none(), "changed options must trigger a relaunch, not reuse (issue #993)" ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Stream: custom viewport is reflected in screencast frame metadata // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_stream_frame_metadata_respects_custom_viewport() { let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]); let socket_dir = std::env::temp_dir().join(format!( "chrome-use-e2e-stream-viewport-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system clock should be after unix epoch") .as_nanos() )); std::fs::create_dir_all(&socket_dir).expect("socket dir should be created"); guard.set( "AGENT_BROWSER_SOCKET_DIR", socket_dir.to_str().expect("socket dir should be utf-8"), ); guard.set("AGENT_BROWSER_SESSION", "e2e-stream-viewport"); let mut state = DaemonState::new(); // Enable stream on an ephemeral port let resp = execute_command( &json!({ "id": "1", "action": "stream_enable", "port": 0 }), &mut state, ) .await; assert_success(&resp); let port = get_data(&resp)["port"] .as_u64() .expect("stream enable should report the bound port"); // Set a custom viewport before launching the browser let resp = execute_command( &json!({ "id": "2", "action": "viewport", "width": 800, "height": 600 }), &mut state, ) .await; assert_success(&resp); // Connect a WebSocket client let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}")) .await .expect("websocket client should connect to runtime stream"); // Navigate to trigger browser launch and screencast let resp = execute_command( &json!({ "id": "3", "action": "navigate", "url": "data:text/html,

Viewport Test

" }), &mut state, ) .await; assert_success(&resp); // Wait for a frame whose JPEG dimensions match the custom viewport. // Early frames may arrive before Chrome fully applies the viewport resize, // so skip frames with stale dimensions rather than failing immediately. let mut found_frame = false; let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(15); while tokio::time::Instant::now() < deadline { let msg = tokio::time::timeout(tokio::time::Duration::from_secs(3), ws.next()).await; let Some(Ok(message)) = msg.ok().flatten() else { continue; }; if !message.is_text() { continue; } let parsed: Value = serde_json::from_str(message.to_text().expect("text message should be readable")) .expect("stream payload should be valid JSON"); if parsed.get("type") == Some(&json!("frame")) { let meta = &parsed["metadata"]; assert_eq!( meta["deviceWidth"], 800, "frame metadata deviceWidth should match custom viewport, got: {}", meta ); assert_eq!( meta["deviceHeight"], 600, "frame metadata deviceHeight should match custom viewport, got: {}", meta ); let data_str = parsed .get("data") .and_then(|v| v.as_str()) .expect("frame message should include base64-encoded 'data' field"); use base64::Engine; let bytes = base64::engine::general_purpose::STANDARD .decode(data_str) .expect("frame data should be valid base64"); let (img_w, img_h) = jpeg_dimensions(&bytes).expect("frame data should be a valid JPEG with SOF marker"); if img_w != 800 || img_h != 600 { continue; } found_frame = true; break; } } assert!( found_frame, "should have received a frame with JPEG dimensions 800x600 within the deadline" ); // Cleanup let resp = execute_command( &json!({ "id": "4", "action": "stream_disable" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); let _ = std::fs::remove_dir_all(&socket_dir); } /// Extract width and height from a JPEG's SOF0 (0xFFC0) or SOF2 (0xFFC2) marker. fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> { for i in 0..data.len().saturating_sub(8) { if data[i] == 0xFF && (data[i + 1] == 0xC0 || data[i + 1] == 0xC2) { let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32; let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32; return Some((width, height)); } } None } // --------------------------------------------------------------------------- // Upload: ref-based selector support (issue #1107) // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn e2e_upload_with_ref_selector() { 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": native_test_fixture_url("upload_probe") }), &mut state, ) .await; assert_success(&resp); let resp = execute_command(&json!({ "id": "3", "action": "snapshot" }), &mut state).await; assert_success(&resp); let snapshot = get_data(&resp)["snapshot"].as_str().unwrap(); // Match by label text, not by role which may vary across Chrome versions let file_input_ref = snapshot .lines() .filter_map(|line| { if line.contains("Choose file") && line.contains("ref=") { let start = line.find("ref=")? + 4; let end = line[start..].find(']')? + start; Some(line[start..end].to_string()) } else { None } }) .next() .expect("Snapshot should contain the file input with a ref"); let tmp = std::env::temp_dir().join(format!("ab-upload-ref-{}.txt", std::process::id())); std::fs::write(&tmp, "test").unwrap(); let resp = execute_command( &json!({ "id": "4", "action": "upload", "selector": file_input_ref, "files": [tmp.to_string_lossy()] }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["uploaded"], 1); let _ = std::fs::remove_file(&tmp); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } #[tokio::test] #[ignore] async fn e2e_upload_with_css_selector() { 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": native_test_fixture_url("upload_probe") }), &mut state, ) .await; assert_success(&resp); let tmp = std::env::temp_dir().join(format!("ab-upload-css-{}.txt", std::process::id())); std::fs::write(&tmp, "test").unwrap(); let resp = execute_command( &json!({ "id": "3", "action": "upload", "selector": "#fileInput", "files": [tmp.to_string_lossy()] }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["uploaded"], 1); let _ = std::fs::remove_file(&tmp); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // Recording: viewport inheritance // --------------------------------------------------------------------------- /// Verify that `recording_start` inherits the current viewport dimensions /// into the newly created recording context. Without this, the recording /// context falls back to the default 1280×720 regardless of what the user set. #[tokio::test] #[ignore] async fn e2e_recording_inherits_viewport() { 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); let resp = execute_command( &json!({ "id": "3", "action": "viewport", "width": 800, "height": 600 }), &mut state, ) .await; assert_success(&resp); let tmp_dir = std::env::temp_dir(); let rec_path = tmp_dir.join(format!("ab-e2e-rec-viewport-{}.webm", std::process::id())); let resp = execute_command( &json!({ "id": "4", "action": "recording_start", "path": rec_path.to_string_lossy() }), &mut state, ) .await; assert_success(&resp); tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; let resp = execute_command( &json!({ "id": "5", "action": "evaluate", "script": "window.innerWidth" }), &mut state, ) .await; assert_success(&resp); let rec_width = get_data(&resp)["result"].as_i64().unwrap(); let resp = execute_command( &json!({ "id": "6", "action": "evaluate", "script": "window.innerHeight" }), &mut state, ) .await; assert_success(&resp); let rec_height = get_data(&resp)["result"].as_i64().unwrap(); assert_eq!( rec_width, 800, "Recording context width should be 800 (inherited from viewport), got {rec_width}" ); assert_eq!( rec_height, 600, "Recording context height should be 600 (inherited from viewport), got {rec_height}" ); let resp = execute_command( &json!({ "id": "7", "action": "recording_stop" }), &mut state, ) .await; assert_success(&resp); let _ = std::fs::remove_file(&rec_path); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); } // --------------------------------------------------------------------------- // --state / storageState flag: cookies should be loaded at launch time // --------------------------------------------------------------------------- /// Verify that launching with `storageState` in the launch command restores /// cookies that were previously saved with `state_save`. /// /// This is the e2e equivalent of `chrome-use --state ./auth.json open `. /// The launch command accepts a `storageState` field that should load the /// state file (cookies + localStorage) before the first navigation. #[tokio::test] #[ignore] async fn e2e_state_flag_restores_cookies() { let state_path = std::env::temp_dir() .join(format!( "chrome-use-e2e-state-flag-{}.json", uuid::Uuid::new_v4() )) .to_string_lossy() .to_string(); // Session 1: launch, set a cookie, save state, close { 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": "cookies_set", "name": "state_flag_test", "value": "from_state_file", "domain": ".example.com", "path": "/", "expires": 2000000000 }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "4", "action": "state_save", "path": &state_path }), &mut state, ) .await; assert_success(&resp); let resp = execute_command(&json!({ "id": "5", "action": "close" }), &mut state).await; assert_success(&resp); } // Session 2: launch with storageState pointing to saved file, verify // cookies are present before any explicit state_load call. { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "10", "action": "launch", "headless": true, "storageState": &state_path }), &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"] == "state_flag_test" && c["value"] == "from_state_file"); assert!( found, "Cookie from state file should be present after launch with storageState. \ 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_file(&state_path); } /// Verify that explicit `launch` surfaces storageState load failures instead /// of reporting success with an empty browser state. #[tokio::test] #[ignore] async fn e2e_state_flag_missing_file_fails_launch() { let guard = EnvGuard::new(&["CI"]); guard.set("CI", "1"); let missing_path = std::env::temp_dir() .join(format!( "chrome-use-e2e-missing-state-{}.json", uuid::Uuid::new_v4() )) .to_string_lossy() .to_string(); let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "10", "action": "launch", "headless": true, "args": ["--no-sandbox", "--disable-dev-shm-usage"], "storageState": &missing_path }), &mut state, ) .await; assert_eq!(resp["success"], false); let error = resp["error"].as_str().unwrap_or_default(); assert!( error.contains("Failed to read state from") || error.contains("storage state"), "Unexpected error for missing storageState file: {}", error ); assert!( state.browser.is_none(), "failed storageState launch should roll back the browser" ); } /// Repeated launch calls with `storageState` should relaunch a clean browser so /// stale cookies do not survive from the previous state file. #[tokio::test] #[ignore] async fn e2e_storage_state_launch_restarts_clean_browser() { let state_one = std::env::temp_dir() .join(format!( "chrome-use-e2e-storage-reuse-1-{}.json", uuid::Uuid::new_v4() )) .to_string_lossy() .to_string(); let state_two = std::env::temp_dir() .join(format!( "chrome-use-e2e-storage-reuse-2-{}.json", uuid::Uuid::new_v4() )) .to_string_lossy() .to_string(); create_storage_state_with_cookie(&state_one, "storage_reload_first", "first").await; create_storage_state_with_cookie(&state_two, "storage_reload_second", "second").await; let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "10", "action": "launch", "headless": true, "args": ["--no-sandbox", "--disable-dev-shm-usage"], "storageState": &state_one }), &mut state, ) .await; assert_success(&resp); assert!( get_data(&resp).get("reused").is_none(), "first launch must create the browser" ); 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(); assert!( cookies .iter() .any(|c| c["name"] == "storage_reload_first" && c["value"] == "first"), "first storageState should be applied on the initial launch" ); let resp = execute_command( &json!({ "id": "13", "action": "launch", "headless": true, "args": ["--no-sandbox", "--disable-dev-shm-usage"], "storageState": &state_two }), &mut state, ) .await; assert_success(&resp); assert_eq!( get_data(&resp).get("reused"), None, "storageState launch should start from a clean browser" ); let resp = execute_command( &json!({ "id": "14", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command(&json!({ "id": "15", "action": "cookies_get" }), &mut state).await; assert_success(&resp); let cookies = get_data(&resp)["cookies"].as_array().unwrap(); assert!( cookies .iter() .any(|c| c["name"] == "storage_reload_second" && c["value"] == "second"), "second storageState should be applied after relaunch" ); assert!( !cookies .iter() .any(|c| c["name"] == "storage_reload_first" && c["value"] == "first"), "stale cookies from the first storageState should not survive relaunch" ); let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; assert_success(&resp); let _ = std::fs::remove_file(&state_one); let _ = std::fs::remove_file(&state_two); } /// Verify that AGENT_BROWSER_STATE env var restores cookies at auto-launch /// time (when the browser is lazily launched by a command like `navigate` /// rather than an explicit `launch` command). #[tokio::test] #[ignore] async fn e2e_state_env_restores_cookies_on_auto_launch() { let state_path = std::env::temp_dir() .join(format!( "chrome-use-e2e-state-env-{}.json", uuid::Uuid::new_v4() )) .to_string_lossy() .to_string(); // Session 1: launch, set a cookie, save state, close { 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": "cookies_set", "name": "env_state_test", "value": "from_env_state", "domain": ".example.com", "path": "/", "expires": 2000000000 }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "4", "action": "state_save", "path": &state_path }), &mut state, ) .await; assert_success(&resp); let resp = execute_command(&json!({ "id": "5", "action": "close" }), &mut state).await; assert_success(&resp); } // Session 2: set AGENT_BROWSER_STATE env var and let auto_launch pick it // up. No explicit `launch` command — just navigate, which triggers // auto_launch internally. { let env = EnvGuard::new(&["AGENT_BROWSER_STATE"]); env.set("AGENT_BROWSER_STATE", &state_path); let mut state = DaemonState::new(); // Navigate without explicit launch — triggers auto_launch let resp = execute_command( &json!({ "id": "10", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command(&json!({ "id": "11", "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"] == "env_state_test" && c["value"] == "from_env_state"); assert!( found, "Cookie should be restored via AGENT_BROWSER_STATE env on auto-launch. \ 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_file(&state_path); } /// Verify that --session-name auto-restores cookies saved from a prior /// session with the same name. #[tokio::test] #[ignore] async fn e2e_session_name_auto_restores_cookies() { let session_name = format!( "e2e-session-name-{}", &uuid::Uuid::new_v4().to_string()[..8] ); let env = EnvGuard::new(&["AGENT_BROWSER_SESSION_NAME"]); env.set("AGENT_BROWSER_SESSION_NAME", &session_name); // Session 1: launch, set a cookie, close (which auto-saves state) { 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": "cookies_set", "name": "session_name_test", "value": "auto_restored", "domain": ".example.com", "path": "/", "expires": 2000000000 }), &mut state, ) .await; assert_success(&resp); // close triggers auto-save when session_name is set let resp = execute_command(&json!({ "id": "5", "action": "close" }), &mut state).await; assert_success(&resp); } // Session 2: fresh DaemonState with same session_name. Navigate without // explicit launch — this triggers auto_launch which calls // try_auto_restore_state. // // NOTE: an explicit `launch` command skips auto_launch entirely, so // session-name auto-restore only fires via the auto_launch path. { let mut state = DaemonState::new(); // Navigate without explicit launch — triggers auto_launch → try_auto_restore_state let resp = execute_command( &json!({ "id": "10", "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"] == "session_name_test" && c["value"] == "auto_restored"); assert!( found, "Cookie should be auto-restored via --session-name. 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); } // Clean up auto-saved state files let sessions_dir = dirs::home_dir() .unwrap() .join(".chrome-use") .join("sessions"); if let Ok(entries) = std::fs::read_dir(&sessions_dir) { for entry in entries.flatten() { let fname = entry.file_name().to_string_lossy().to_string(); if fname.starts_with(&format!("{}-", session_name)) { let _ = std::fs::remove_file(entry.path()); } } } } /// Verify that explicit `state_load` restores cookies into an existing /// session (baseline sanity check — this path is known to work). #[tokio::test] #[ignore] async fn e2e_explicit_state_load_restores_cookies() { let state_path = std::env::temp_dir() .join(format!( "chrome-use-e2e-explicit-load-{}.json", uuid::Uuid::new_v4() )) .to_string_lossy() .to_string(); // Session 1: set cookie, save state { 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": "cookies_set", "name": "explicit_load_test", "value": "manually_loaded", "domain": ".example.com", "path": "/", "expires": 2000000000 }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "4", "action": "state_save", "path": &state_path }), &mut state, ) .await; assert_success(&resp); let resp = execute_command(&json!({ "id": "5", "action": "close" }), &mut state).await; assert_success(&resp); } // Session 2: launch clean, then explicitly load state { let mut state = DaemonState::new(); let resp = execute_command( &json!({ "id": "10", "action": "launch", "headless": true }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "11", "action": "state_load", "path": &state_path }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "12", "action": "navigate", "url": "https://example.com" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command(&json!({ "id": "13", "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"] == "explicit_load_test" && c["value"] == "manually_loaded"); assert!( found, "Cookie should be present after explicit state_load. 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_file(&state_path); } // === React / Web Vitals primitives === const REACT_FIXTURE_HTML: &str = r#" React fixture
"#; fn react_fixture_url() -> String { format!( "data:text/html;base64,{}", STANDARD.encode(REACT_FIXTURE_HTML) ) } #[tokio::test] #[ignore] async fn e2e_react_tree_errors_without_hook() { 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); // Without --enable react-devtools, the hook isn't installed and the // command should error. let resp = execute_command(&json!({ "id": "3", "action": "react_tree" }), &mut state).await; let err = resp .get("error") .and_then(|v| v.as_str()) .unwrap_or_default(); assert!( err.contains("React DevTools") || err.contains("renderer"), "Expected hook-missing error, got: {:?}", resp ); let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; } #[tokio::test] #[ignore] async fn e2e_react_tree_with_enable_hook() { let guard = EnvGuard::new(&["AGENT_BROWSER_ENABLE"]); guard.set("AGENT_BROWSER_ENABLE", "react-devtools"); 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": &react_fixture_url() }), &mut state, ) .await; assert_success(&resp); // Give React a moment to boot and register with the hook. tokio::time::sleep(std::time::Duration::from_millis(1500)).await; let resp = execute_command(&json!({ "id": "3", "action": "react_tree" }), &mut state).await; assert_success(&resp); let tree = get_data(&resp) .get("tree") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); assert!( tree.contains("App"), "Expected tree to contain 'App': {}", tree ); assert!( tree.contains("Counter"), "Expected tree to contain 'Counter': {}", tree ); let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; } #[tokio::test] #[ignore] async fn e2e_vitals_reports_metrics() { 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": &react_fixture_url() }), &mut state, ) .await; assert_success(&resp); let resp = execute_command(&json!({ "id": "3", "action": "vitals" }), &mut state).await; assert_success(&resp); let report = get_data(&resp) .get("report") .and_then(|v| v.as_str()) .unwrap_or_default(); assert!( report.contains("Core Web Vitals"), "Expected vitals report, got: {}", report ); assert!(report.contains("TTFB")); assert!(report.contains("CLS")); let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; } #[tokio::test] #[ignore] async fn e2e_pushstate_changes_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": "navigate", "url": "https://example.com/" }), &mut state, ) .await; assert_success(&resp); let resp = execute_command( &json!({ "id": "3", "action": "pushstate", "url": "/newpath" }), &mut state, ) .await; assert_success(&resp); let url = get_data(&resp) .get("url") .and_then(|v| v.as_str()) .unwrap_or_default(); assert!( url.ends_with("/newpath"), "Expected pushstate URL to end with /newpath, got: {}", url ); let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; } #[tokio::test] #[ignore] async fn e2e_removeinitscript_roundtrip() { 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": "addinitscript", "script": "window.__AB_ROUNDTRIP__ = 1;" }), &mut state, ) .await; assert_success(&resp); let identifier = get_data(&resp)["identifier"] .as_str() .expect("addinitscript should return an identifier") .to_string(); assert!(!identifier.is_empty()); let resp = execute_command( &json!({ "id": "4", "action": "removeinitscript", "identifier": identifier }), &mut state, ) .await; assert_success(&resp); assert_eq!(get_data(&resp)["removed"], true); let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await; }