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
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.
5501 lines
170 KiB
Rust
5501 lines
170 KiB
Rust
//! 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;
|
||
}
|