Files
chrome-use/cli/src/native/e2e_tests.rs
T
leeguooooo 61060486f4
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
rebrand: agent-browser-stealth → chrome-use, de-fork, reset to v1.0.0
Standalone product rename across the whole repo (issue: project identity):

- Binary/package/repo/skill/docs: agent-browser[-stealth] → chrome-use
  (single binary name `chrome-use`; old aliases agent-browser/abs dropped).
- Version: 0.27.0-fork.51 → 1.0.0 (drop the upstream-fork counter).
- Native-messaging host: com.agent_browser.connect → com.leeguoo.chrome_use
  (CLI + ab-connect extension in lockstep — this is a breaking handshake change,
  extension bumped 0.4.2 → 0.5.0, needs a Web Store republish).
- Config dir: ~/.agent-browser → ~/.chrome-use.
- README/zh: reframed from "stealth fork of agent-browser" to a standalone
  product with a small `originally based on vercel-labs/agent-browser` credit.
- Kept AGENT_BROWSER_* env vars working (63 vars across the codebase; renaming
  them would break every existing script/skill for no user-facing gain).

Build green, 802 unit tests pass, fmt + clippy clean. Upstream attribution to
vercel-labs/agent-browser preserved.
2026-06-12 12:56:21 +09:00

5501 lines
170 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String> {
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,<h1>Runtime Stream</h1>" }),
&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##"
<html><body>
<button onclick="document.getElementById('result').textContent = 'clicked'">Submit</button>
<a href="#">Home</a>
<div id="result"></div>
</body></html>
"##,
}),
&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,<html><body>",
"<input id='name' type='text' placeholder='Name'>",
"<input id='email' type='email'>",
"<select id='color'><option value='red'>Red</option><option value='blue'>Blue</option></select>",
"<input id='agree' type='checkbox'>",
"<textarea id='bio'></textarea>",
"<button id='submit'>Submit</button>",
"</body></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,<h1>Page 1</h1>" }),
&mut state,
)
.await;
assert_success(&resp);
// Navigate to page 2
let resp = execute_command(
&json!({ "id": "3", "action": "navigate", "url": "data:text/html,<h1>Page 2</h1>" }),
&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,<h1>Tab 1</h1>" }),
&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,<h1>Tab 2</h1>" }),
&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,<h1>Tab 2</h1>" }),
&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,<h1>Tab 3</h1>" }),
&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,<h1>Tab 4</h1>" }),
&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<String> = 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,<title>A</title>" }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "3", "action": "tab_new", "url": "data:text/html,<title>B</title>" }),
&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<N>` 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,<title>Home</title>" }),
&mut state,
)
.await;
assert_success(&resp);
// Open a labeled tab and verify the response echoes the label and a
// `t<N>` style tabId.
let resp = execute_command(
&json!({
"id": "3",
"action": "tab_new",
"url": "data:text/html,<title>Docs</title>",
"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<N>`).
#[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<N>` 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,<html><body>",
"<p id='visible'>Hello World</p>",
"<p id='hidden' style='display:none'>Hidden</p>",
"<input id='enabled' value='test'>",
"<input id='disabled' disabled value='nope'>",
"<a id='link' href='https://example.com' data-testid='my-link'>Click me</a>",
"</body></html>"
);
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,<html><body>",
"<div id='target' style='display:none'>Appeared!</div>",
"<script>setTimeout(() => document.getElementById('target').style.display='block', 500)</script>",
"</body></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,<html><body><script>
// On first load, change URL via pushState without navigation
history.pushState({}, '', '/#/home');
</script><h1>Test</h1></body></html>";
// 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,<h1>Viewport</h1>" }),
&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,<html><body style='height:3000px'>",
"<button id='btn' onmouseover=\"this.textContent='hovered'\">Hover me</button>",
"<input id='input' onkeydown=\"this.dataset.key=event.key\">",
"</body></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,<h1>Hello</h1><p>World</p>" }),
&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,<html><body>",
"<input id='a' value='original'>",
"<input id='b' value='other'>",
"<p class='item'>One</p>",
"<p class='item'>Two</p>",
"<p class='item'>Three</p>",
"<div id='box' style='width:200px;height:100px;background:red'>Box</div>",
"</body></html>"
);
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,<h1>Auto</h1>" }),
&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,<h1>Errors</h1>" }),
&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::<Vec<_>>()
);
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:<port>, 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,<body>
<div id="c">
<button onclick="
var c = document.getElementById('c');
c.innerHTML = '';
var b = document.createElement('button');
b.textContent = 'Target';
b.onclick = function() { document.title = 'clicked'; };
c.appendChild(b);
document.title = 'replaced';
">Replace</button>
<button>Target</button>
</div>
</body>"#;
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 <input> 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 <input> 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,<html><body>",
// -- Native baseline --
"<input id='native' type='checkbox'>",
// -- Material-style hidden-input checkbox --
"<div id='mat' style='position:relative;padding:12px'>",
"<input id='mat-input' type='checkbox' style='position:absolute;opacity:0;width:1px;height:1px;top:-9999px;left:-9999px;pointer-events:none'>",
"<div style='position:absolute;top:0;left:0;width:48px;height:48px;pointer-events:all;z-index:10'></div>",
"<span>Material CB</span>",
"</div>",
// -- ARIA-only checkbox (no native input) --
"<div id='aria' role='checkbox' aria-checked='false' tabindex='0'>ARIA CB</div>",
"<script>",
"document.getElementById('aria').addEventListener('click',function(){",
"var c=this.getAttribute('aria-checked')==='true';",
"this.setAttribute('aria-checked',String(!c));",
"});",
"</script>",
"</body></html>"
);
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:
// - <button> and <a> (standard interactive ARIA tree)
// - <div cursor:pointer onclick> (clickable cursor section)
// - <div tabindex=0> (focusable cursor section)
// - <span cursor:pointer> (clickable cursor section)
// - <span cursor:pointer> child of <div cursor:pointer> (inherited skip)
let html = concat!(
"<html><body>",
"<a href='#'>Link</a>",
"<button>Btn</button>",
"<div style='cursor:pointer' onclick='x()'>ClickDiv</div>",
"<div tabindex='0'>FocusDiv</div>",
"<span style='cursor:pointer'>PointerSpan</span>",
"<div style='cursor:pointer'><span>InheritChild</span></div>",
"</body></html>",
);
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 <a> element should not have cursor-interactive info:\n{}",
line
);
assert!(
!(line.contains("\"Btn\"")
&& (line.contains("clickable")
|| line.contains("focusable")
|| line.contains("editable"))),
"Standard <button> element should not have cursor-interactive info:\n{}",
line
);
}
// Must complete quickly (< 5s), not hit the 30s CDP timeout
assert!(
elapsed.as_secs() < 5,
"snapshot took {:?}, expected < 5s (Issue #841 regression)",
elapsed,
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
/// Verifies that `screenshot --annotate` completes in bounded time even with
/// many interactive elements. Guards against the sequential CDP round-trip
/// regression that caused hangs over high-latency WSS (Issue #841).
#[tokio::test]
#[ignore]
async fn e2e_screenshot_annotate_many_elements() {
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// 50 buttons: old sequential code would do 50×2×200ms ≈ 20s over WSS.
let mut html = String::from("<html><body>");
for i in 1..=50 {
html.push_str(&format!("<button>Button {}</button>", i));
}
html.push_str("</body></html>");
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("<html><body>");
for i in 1..=100 {
html.push_str(&format!(
"<div style='cursor:pointer' onclick='x()'>Item {}</div>",
i,
));
}
html.push_str("</body></html>");
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,<html><body><div><span>Hello</span> <span>World</span></div></body></html>";
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#"<!doctype html>
<html>
<head><meta charset="utf-8"><title>Delayed Login</title></head>
<body>
<input id="search" type="text" name="search" />
<div id="root">loading...</div>
<script>
setInterval(() => {{
fetch('/ping?ts=' + Date.now()).catch(() => {{}});
}}, {ping_interval_ms});
setTimeout(() => {{
const root = document.getElementById('root');
root.innerHTML = `
<form id="login-form">
<input type="email" name="email" />
<input type="password" name="password" />
<button type="submit">Sign in</button>
</form>
`;
document.getElementById('login-form').addEventListener('submit', function(e) {{
e.preventDefault();
e.stopPropagation();
window.__submitted = true;
}});
}}, {render_delay_ms});
</script>
</body>
</html>"#,
);
("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,<h1>External Tab</h1>" })),
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,<h1>Viewport Test</h1>" }),
&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,<h1>Viewport</h1>" }),
&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 <url>`.
/// 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::<Vec<_>>()
);
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::<Vec<_>>()
);
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::<Vec<_>>()
);
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::<Vec<_>>()
);
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#"<!doctype html>
<html>
<head><title>React fixture</title></head>
<body>
<div id="root"></div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script>
const { useState, createElement: h } = React;
function Counter({ label }) {
const [n, setN] = useState(0);
return h("button", { onClick: () => setN(n + 1) }, label + ": " + n);
}
function App() {
return h("div", {}, [
h("h1", { key: "t" }, "Hello"),
h(Counter, { key: "c1", label: "A" }),
h(Counter, { key: "c2", label: "B" }),
]);
}
ReactDOM.createRoot(document.getElementById("root")).render(h(App));
</script>
</body>
</html>
"#;
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;
}