mirror of
https://github.com/actionbook/actionbook.git
synced 2026-09-19 03:28:06 +08:00
e00c48fd28
* test: add hover command cases * test: add hover text error coverage * feat: implement browser hover command Add the `browser hover <selector>` command. Resolves the selector to a DOM node via the shared element module, then dispatches mouseenter, mouseover, and mousemove events via Runtime.callFunctionOn for reliable hover semantics in headless Chrome. Wired up across CLI parsing, action enum, router, and output formatting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add focus command cases * feat: implement browser focus command Add the `browser focus <selector>` command. Resolves the selector via the shared element module, uses DOM.focus to set focus on the target, and compares pre/post active element by JS reference identity (===) to correctly report focus_changed even for anonymous elements. Wired up across CLI parsing, action enum, router, and output formatting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add press command cases * feat: implement browser press command Add the `browser press <key-or-chord>` command. Parses single keys and chord combinations (e.g. Control+A, Shift+Tab), validates modifier segments, and dispatches keyDown/keyUp via CDP Input.dispatchKeyEvent with the appropriate modifiers bitmask. Wired up across CLI parsing, action enum, router, and output formatting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add drag command cases * feat: implement browser drag command Adds drag-and-drop via CDP Input.dispatchMouseEvent sequence: mousePressed at source → mouseMoved along path → mouseReleased at destination. Destination accepts either a selector or x,y coordinates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: drag dispatch via viewport resize + CDP Input events CDP Input.dispatchMouseEvent requires elements to be within the visible viewport. The drag fixture positions elements at top:780px which exceeds the default headless Chrome viewport (~469px). Fix by temporarily enlarging the viewport via Emulation.setDeviceMetricsOverride before the drag sequence, then clearing the override after completion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resize viewport before resolving drag coordinates Move Emulation.setDeviceMetricsOverride before element resolution so coordinates are captured under the final viewport. Avoids stale coordinates on responsive pages where a post-resolution resize could shift element positions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: preserve viewport width during drag, only expand height if needed Read the current viewport dimensions and only override if the height is too short for the drag endpoints. Width is preserved to avoid flipping responsive breakpoints or triggering resize handlers. The override is skipped entirely when the viewport is already tall enough. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add upload command cases * feat: implement browser upload command Adds `browser upload <selector> <file...> --session --tab` which sets files on a file input via CDP DOM.setFileInputFiles. Validates that all paths are absolute, resolves the target node, and returns a value_summary with file list and count. All 10 e2e tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: require at least one file path in browser upload Adds `#[arg(required = true, num_args = 1..)]` to the `files` field so Clap rejects invocations with zero files at parse time, matching the spec's `<file...>` contract. Adds a unit test for the missing-files case. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add eval command cases * fix: update browser eval to return typed value, type, and preview per spec - Return `data.value` as typed JSON (number/bool/string) instead of always stringifying - Add `data.type` (JS type from CDP) and `data.preview` (string repr) - Return null context for SESSION_NOT_FOUND - Extract exception description for better EVAL_FAILED error messages - Update cloud eval tests to assert typed values All 11 eval tests pass (8 interaction + 3 cloud). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add eval context coverage * fix: include tab URL and title in browser eval context Fetches the current page URL and title after eval execution and returns them in the response context, matching the spec contract for tab-scoped commands. Tests now assert context.url and context.title are present. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add mouse-move command cases * feat: implement browser mouse-move command Adds `browser mouse-move <x,y> --session --tab` which dispatches a CDP Input.dispatchMouseEvent (mouseMoved) at the given coordinates. Parses and validates x,y format, tracks url/focus changes, returns context with tab URL and title. All 8 e2e tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add cursor-position command cases * test: add cursor-position text status check * feat: implement browser cursor-position command Adds cursor-position command that returns the last known cursor position for a tab. Position is stored in SessionRegistry when mouse-move is executed, using the existing session_id\0tab_id composite key pattern. Wired in all 6 registration points: mod.rs, action.rs, cli.rs (enum + to_action + command_name + context), router.rs, output.rs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: track cursor position in click, drag, and hover commands cursor-position was only updated by mouse-move. Now click, drag, and hover also store the final pointer coordinates in SessionRegistry so that cursor-position reflects the true position after any mouse action. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add regression test for click -> cursor-position tracking Verifies that cursor-position reflects coordinates from a click action, not just mouse-move. Covers the contract gap identified in review. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add scroll command cases * test: add scroll changed field checks * feat: implement browser scroll commands Adds scroll command with three subcommand forms: - Directional: scroll up|down|left|right <pixels> - Edge: scroll top|bottom - Into-view: scroll into-view <selector> [--align start|center|end|nearest] All forms support --container for scrolling within a specific element. Tracks scroll_changed, url_changed, focus_changed in response data. Wired in all 6 registration points. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add scroll xpath selector coverage * fix: use resolved CDP nodes for scroll selectors instead of querySelector Refactored scroll to resolve selectors via element::resolve_node + DOM.resolveNode → objectId, then use Runtime.callFunctionOn for all scroll operations. This correctly supports CSS, XPath, and @eN selectors per the API contract, instead of raw querySelector which only handles CSS. Added DOM.getDocument init call to ensure XPath DOM.requestNode works. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1686 lines
52 KiB
Rust
1686 lines
52 KiB
Rust
//! Cloud mode E2E tests.
|
|
//!
|
|
//! Tests cloud browser connectivity via `--mode cloud --cdp-endpoint <wss://...>`.
|
|
//!
|
|
//! Two tiers:
|
|
//! 1. Local Chrome simulating cloud — gated by `RUN_E2E_TESTS=true`
|
|
//! Uses `ws://127.0.0.1:PORT/devtools/browser/...` against a locally launched Chrome.
|
|
//! 2. Real cloud browser (hyperbrowse) — gated by `ACTIONBOOK_CLOUD_CDP_ENDPOINT`
|
|
//! Connects to an actual cloud endpoint with optional `ACTIONBOOK_CLOUD_HEADERS`.
|
|
//!
|
|
//! Uses shared assertion helpers from harness — no code duplication.
|
|
|
|
use crate::harness::{
|
|
SessionGuard, SoloEnv, assert_context_with_session, assert_context_with_tab,
|
|
assert_error_envelope, assert_failure, assert_meta, assert_success, assert_tab_id, headless,
|
|
headless_json, parse_json, skip, stdout_str, url_a, url_b, url_c,
|
|
};
|
|
use std::env;
|
|
use std::process::Command as StdCommand;
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
/// RAII guard for external Chrome process + temp directory cleanup.
|
|
struct ChromeGuard {
|
|
child: std::process::Child,
|
|
_tmp: tempfile::TempDir,
|
|
}
|
|
|
|
impl Drop for ChromeGuard {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
// _tmp drops here, cleaning up the user-data-dir
|
|
}
|
|
}
|
|
|
|
/// Launch a headless Chrome on a random port for simulating cloud.
|
|
/// Returns (guard, ws_url).
|
|
fn launch_simulated_cloud() -> (ChromeGuard, String) {
|
|
let chrome = find_chrome_executable();
|
|
let tmp_dir = tempfile::tempdir().expect("create temp dir for cloud chrome");
|
|
let user_data_dir = tmp_dir.path().to_string_lossy().to_string();
|
|
let mut child = StdCommand::new(&chrome)
|
|
.args([
|
|
"--headless=new",
|
|
"--remote-debugging-port=0",
|
|
"--no-first-run",
|
|
"--no-default-browser-check",
|
|
&format!("--user-data-dir={user_data_dir}"),
|
|
])
|
|
.stderr(std::process::Stdio::piped())
|
|
.stdout(std::process::Stdio::null())
|
|
.stdin(std::process::Stdio::null())
|
|
.spawn()
|
|
.expect("failed to launch Chrome for simulated cloud");
|
|
|
|
let stderr = child.stderr.take().expect("stderr should be piped");
|
|
use std::io::BufRead;
|
|
let reader = std::io::BufReader::new(stderr);
|
|
let mut ws_url = String::new();
|
|
let start = std::time::Instant::now();
|
|
for line in reader.lines() {
|
|
if start.elapsed() > std::time::Duration::from_secs(15) {
|
|
panic!("Chrome did not print DevTools URL within 15s");
|
|
}
|
|
let line: String = line.expect("read stderr line");
|
|
if line.contains("DevTools listening on")
|
|
&& let Some(idx) = line.find("ws://")
|
|
{
|
|
ws_url = line[idx..].trim().to_string();
|
|
break;
|
|
}
|
|
}
|
|
assert!(!ws_url.is_empty(), "failed to get Chrome WS URL");
|
|
(
|
|
ChromeGuard {
|
|
child,
|
|
_tmp: tmp_dir,
|
|
},
|
|
ws_url,
|
|
)
|
|
}
|
|
|
|
fn find_chrome_executable() -> String {
|
|
let candidates = [
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
"google-chrome",
|
|
"google-chrome-stable",
|
|
"chromium",
|
|
"chromium-browser",
|
|
];
|
|
for c in &candidates {
|
|
if std::path::Path::new(c).exists() {
|
|
return c.to_string();
|
|
}
|
|
if let Ok(output) = StdCommand::new("which").arg(c).output()
|
|
&& output.status.success()
|
|
{
|
|
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !path.is_empty() {
|
|
return path;
|
|
}
|
|
}
|
|
}
|
|
panic!("Chrome not found for cloud simulation tests");
|
|
}
|
|
|
|
/// Check if real cloud tests are enabled. Returns (endpoint, headers).
|
|
#[allow(dead_code)]
|
|
fn cloud_endpoint() -> Option<(String, Vec<String>)> {
|
|
let endpoint = env::var("ACTIONBOOK_CLOUD_CDP_ENDPOINT").ok()?;
|
|
let headers: Vec<String> = env::var("ACTIONBOOK_CLOUD_HEADERS")
|
|
.unwrap_or_default()
|
|
.split(',')
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
Some((endpoint, headers))
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn skip_cloud_real() -> bool {
|
|
cloud_endpoint().is_none()
|
|
}
|
|
|
|
/// Assert cloud session JSON response structure.
|
|
fn assert_cloud_session(v: &serde_json::Value) {
|
|
assert_eq!(v["ok"], true);
|
|
assert!(v["error"].is_null(), "error must be null on success");
|
|
assert_eq!(v["command"], "browser.start");
|
|
let session = &v["data"]["session"];
|
|
assert_eq!(session["mode"], "cloud");
|
|
assert_eq!(session["status"], "running");
|
|
assert!(
|
|
session["cdp_endpoint"].is_string(),
|
|
"cloud session must include cdp_endpoint"
|
|
);
|
|
assert!(
|
|
session.get("headers").is_none() || session["headers"].is_null(),
|
|
"headers must not be exposed in session output"
|
|
);
|
|
assert_meta(v);
|
|
}
|
|
|
|
// ── Cloud session helpers ───────────────────────────────────────────
|
|
|
|
fn start_cloud_json(ws_url: &str) -> (String, String) {
|
|
let out = headless_json(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
ws_url,
|
|
],
|
|
30,
|
|
);
|
|
assert_success(&out, "cloud start");
|
|
let v = parse_json(&out);
|
|
let sid = v["data"]["session"]["session_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
let tid = v["data"]["tab"]["tab_id"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.to_string();
|
|
(sid, tid)
|
|
}
|
|
|
|
fn start_cloud_with_headers_json(ws_url: &str, headers: &[&str]) -> (String, String) {
|
|
let mut args = vec![
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
ws_url,
|
|
];
|
|
for h in headers {
|
|
args.push("--header");
|
|
args.push(h);
|
|
}
|
|
let out = headless_json(&args, 30);
|
|
assert_success(&out, "cloud start with headers");
|
|
let v = parse_json(&out);
|
|
let sid = v["data"]["session"]["session_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
let tid = v["data"]["tab"]["tab_id"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.to_string();
|
|
(sid, tid)
|
|
}
|
|
|
|
fn open_cloud_tab_json(sid: &str, url: &str) -> String {
|
|
let out = headless_json(&["browser", "new-tab", url, "--session", sid], 15);
|
|
assert_success(&out, "cloud new-tab");
|
|
let v = parse_json(&out);
|
|
v["data"]["tab"]["tab_id"].as_str().unwrap().to_string()
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 1: Cloud session lifecycle
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_start_and_close_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
|
|
let out = headless_json(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
&ws_url,
|
|
],
|
|
30,
|
|
);
|
|
assert_success(&out, "cloud start");
|
|
let v = parse_json(&out);
|
|
assert_cloud_session(&v);
|
|
|
|
let sid = v["data"]["session"]["session_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
let tab_id = v["data"]["tab"]["tab_id"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let _guard = SessionGuard::new(&sid);
|
|
assert!(!sid.is_empty(), "session_id should not be empty");
|
|
assert!(!tab_id.is_empty(), "should discover at least one tab");
|
|
assert!(
|
|
!v["data"]["reused"].as_bool().unwrap_or(true),
|
|
"first start should not be reused"
|
|
);
|
|
|
|
assert_context_with_tab(&v, &sid, &tab_id);
|
|
|
|
let out = headless_json(&["browser", "close", "--session", &sid], 10);
|
|
assert_success(&out, "cloud close");
|
|
let v = parse_json(&out);
|
|
assert_eq!(v["ok"], true);
|
|
assert!(v["error"].is_null());
|
|
assert_eq!(v["data"]["status"], "closed");
|
|
assert_context_with_session(&v, &sid);
|
|
assert_meta(&v);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_start_and_close_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
|
|
let out = headless(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
&ws_url,
|
|
],
|
|
30,
|
|
);
|
|
assert_success(&out, "cloud start text");
|
|
let text = stdout_str(&out);
|
|
assert!(text.contains("ok browser.start"));
|
|
assert!(text.contains("mode: cloud"));
|
|
|
|
let first_line = text.lines().next().unwrap_or("");
|
|
let sid = first_line
|
|
.trim_start_matches('[')
|
|
.split_whitespace()
|
|
.next()
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let _guard = SessionGuard::new(&sid);
|
|
assert!(!sid.is_empty(), "should extract session_id from text");
|
|
|
|
let out = headless(&["browser", "close", "--session", &sid], 10);
|
|
assert_success(&out, "cloud close text");
|
|
let text = stdout_str(&out);
|
|
assert!(text.contains("ok browser.close"));
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 2: Cloud session reuse
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_reuse_same_endpoint_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
|
|
let (sid1, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid1);
|
|
|
|
let out = headless_json(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
&ws_url,
|
|
],
|
|
30,
|
|
);
|
|
assert_success(&out, "cloud start 2 (reuse)");
|
|
let v = parse_json(&out);
|
|
let sid2 = v["data"]["session"]["session_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
|
|
assert_eq!(sid1, sid2, "same endpoint must reuse session");
|
|
assert!(
|
|
v["data"]["reused"].as_bool().unwrap_or(false),
|
|
"second start must be reused=true"
|
|
);
|
|
assert_meta(&v);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_reuse_same_endpoint_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let out = headless(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
&ws_url,
|
|
],
|
|
30,
|
|
);
|
|
assert_success(&out, "cloud reuse text");
|
|
let text = stdout_str(&out);
|
|
assert!(text.contains(&sid), "text should contain same session_id");
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_reuse_with_different_headers_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
|
|
let (sid1, _) = start_cloud_with_headers_json(&ws_url, &["Authorization:Bearer old-token"]);
|
|
let _guard = SessionGuard::new(&sid1);
|
|
|
|
let out = headless_json(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
&ws_url,
|
|
"--header",
|
|
"Authorization:Bearer new-token",
|
|
],
|
|
30,
|
|
);
|
|
assert_success(&out, "cloud reuse different headers");
|
|
let v = parse_json(&out);
|
|
let sid2 = v["data"]["session"]["session_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
|
|
assert_eq!(
|
|
sid1, sid2,
|
|
"same endpoint with different headers should still reuse"
|
|
);
|
|
assert!(v["data"]["reused"].as_bool().unwrap_or(false));
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 3: Error cases
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_missing_cdp_endpoint_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
|
|
let out = headless_json(&["browser", "start", "--mode", "cloud"], 10);
|
|
assert_failure(&out, "cloud missing endpoint");
|
|
let v = parse_json(&out);
|
|
assert_error_envelope(&v, "MISSING_CDP_ENDPOINT");
|
|
assert!(v["error"]["hint"].is_string(), "error should have hint");
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_missing_cdp_endpoint_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
|
|
let out = headless(&["browser", "start", "--mode", "cloud"], 10);
|
|
assert_failure(&out, "cloud missing endpoint text");
|
|
let text = stdout_str(&out);
|
|
assert!(
|
|
text.contains("MISSING_CDP_ENDPOINT") || text.contains("cdp-endpoint"),
|
|
"error should mention cdp-endpoint: {text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_invalid_endpoint_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
|
|
let out = headless_json(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
"ws://127.0.0.1:1/invalid",
|
|
],
|
|
15,
|
|
);
|
|
assert_failure(&out, "cloud invalid endpoint");
|
|
let v = parse_json(&out);
|
|
assert_eq!(v["ok"], false);
|
|
let code = v["error"]["code"].as_str().unwrap_or("");
|
|
assert!(
|
|
code == "CDP_CONNECTION_FAILED" || code == "CLOUD_CONNECTION_LOST",
|
|
"expected connection error, got: {code}"
|
|
);
|
|
assert_meta(&v);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_invalid_header_format_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
|
|
let out = headless_json(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
&ws_url,
|
|
"--header",
|
|
"InvalidHeaderNoColon",
|
|
],
|
|
15,
|
|
);
|
|
assert_failure(&out, "invalid header format");
|
|
let v = parse_json(&out);
|
|
assert_eq!(v["ok"], false);
|
|
let code = v["error"]["code"].as_str().unwrap_or("");
|
|
assert!(
|
|
code == "INVALID_ARGUMENT" || code == "INVALID_HEADER",
|
|
"expected invalid argument error for malformed header, got: {code}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_header_with_colon_in_value_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
|
|
let out = headless_json(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
&ws_url,
|
|
"--header",
|
|
"Authorization:Bearer abc:def:ghi",
|
|
],
|
|
30,
|
|
);
|
|
assert_success(&out, "header with colon in value");
|
|
let v = parse_json(&out);
|
|
assert_cloud_session(&v);
|
|
|
|
let sid = v["data"]["session"]["session_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
let _guard = SessionGuard::new(&sid);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 4: Cloud tab management — JSON
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_list_tabs_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let out = headless_json(&["browser", "list-tabs", "--session", &sid], 10);
|
|
assert_success(&out, "cloud list-tabs");
|
|
let v = parse_json(&out);
|
|
|
|
assert_eq!(v["ok"], true);
|
|
assert!(v["error"].is_null());
|
|
assert_eq!(v["command"], "browser.list-tabs");
|
|
assert_context_with_session(&v, &sid);
|
|
assert!(
|
|
v["data"]["total_tabs"].as_u64().unwrap_or(0) >= 1,
|
|
"should have >=1 tab"
|
|
);
|
|
let tabs = v["data"]["tabs"].as_array().expect("tabs should be array");
|
|
for tab in tabs {
|
|
assert_tab_id(&tab["tab_id"]);
|
|
}
|
|
assert_meta(&v);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_list_tabs_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let out = headless(&["browser", "list-tabs", "--session", &sid], 10);
|
|
assert_success(&out, "cloud list-tabs text");
|
|
let text = stdout_str(&out);
|
|
assert!(text.contains("tab"), "text should contain tab info: {text}");
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_new_tab_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let url = url_a();
|
|
let out = headless_json(&["browser", "new-tab", &url, "--session", &sid], 15);
|
|
assert_success(&out, "cloud new-tab");
|
|
let v = parse_json(&out);
|
|
|
|
assert_eq!(v["ok"], true);
|
|
assert!(v["error"].is_null());
|
|
assert_eq!(v["command"], "browser.new-tab");
|
|
assert_eq!(v["data"]["created"], true);
|
|
assert_tab_id(&v["data"]["tab"]["tab_id"]);
|
|
assert_context_with_session(&v, &sid);
|
|
assert!(
|
|
v["context"]["tab_id"].is_string(),
|
|
"context should have tab_id for new-tab"
|
|
);
|
|
assert_meta(&v);
|
|
|
|
let out = headless_json(&["browser", "list-tabs", "--session", &sid], 10);
|
|
let v = parse_json(&out);
|
|
assert!(
|
|
v["data"]["total_tabs"].as_u64().unwrap_or(0) >= 2,
|
|
"should have >=2 tabs"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_new_tab_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let url = url_a();
|
|
let out = headless(&["browser", "new-tab", &url, "--session", &sid], 15);
|
|
assert_success(&out, "cloud new-tab text");
|
|
let text = stdout_str(&out);
|
|
assert!(!text.is_empty(), "new-tab text should not be empty: {text}");
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_new_tab_sequential_ids_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, t1) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let t2 = open_cloud_tab_json(&sid, &url_a());
|
|
let t3 = open_cloud_tab_json(&sid, &url_b());
|
|
|
|
assert_ne!(t1, t2, "tab IDs must be unique");
|
|
assert_ne!(t2, t3, "tab IDs must be unique");
|
|
assert_ne!(t1, t3, "tab IDs must be unique");
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_close_tab_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _t1) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
let url = url_a();
|
|
let t2 = open_cloud_tab_json(&sid, &url);
|
|
|
|
let out = headless_json(
|
|
&["browser", "close-tab", "--session", &sid, "--tab", &t2],
|
|
15,
|
|
);
|
|
assert_success(&out, "cloud close-tab");
|
|
let v = parse_json(&out);
|
|
|
|
assert_eq!(v["ok"], true);
|
|
assert!(v["error"].is_null());
|
|
assert_eq!(v["command"], "browser.close-tab");
|
|
assert_eq!(v["data"]["closed_tab_id"], t2);
|
|
assert_context_with_tab(&v, &sid, &t2);
|
|
assert_meta(&v);
|
|
|
|
let out = headless_json(&["browser", "list-tabs", "--session", &sid], 10);
|
|
let v = parse_json(&out);
|
|
let tab_ids: Vec<&str> = v["data"]["tabs"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter_map(|t| t["tab_id"].as_str())
|
|
.collect();
|
|
assert!(
|
|
!tab_ids.contains(&t2.as_str()),
|
|
"closed tab should not appear"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_close_tab_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
let url = url_a();
|
|
let t2 = open_cloud_tab_json(&sid, &url);
|
|
|
|
let out = headless(
|
|
&["browser", "close-tab", "--session", &sid, "--tab", &t2],
|
|
15,
|
|
);
|
|
assert_success(&out, "cloud close-tab text");
|
|
let text = stdout_str(&out);
|
|
assert!(
|
|
!text.is_empty(),
|
|
"close-tab text should not be empty: {text}"
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 5: Cloud tab error cases
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_list_tabs_nonexistent_session_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
|
|
let out = headless_json(&["browser", "list-tabs", "--session", "ghost-cloud"], 10);
|
|
assert_failure(&out, "list-tabs nonexistent session");
|
|
let v = parse_json(&out);
|
|
assert_eq!(v["command"], "browser.list-tabs");
|
|
assert_error_envelope(&v, "SESSION_NOT_FOUND");
|
|
assert!(
|
|
v["context"].is_null(),
|
|
"context should be null for SESSION_NOT_FOUND"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_list_tabs_nonexistent_session_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
|
|
let out = headless(&["browser", "list-tabs", "--session", "ghost-cloud"], 10);
|
|
assert_failure(&out, "list-tabs nonexistent session text");
|
|
let text = stdout_str(&out);
|
|
assert!(text.contains("SESSION_NOT_FOUND"), "text: {text}");
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_new_tab_nonexistent_session_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
|
|
let url = url_a();
|
|
let out = headless_json(
|
|
&["browser", "new-tab", &url, "--session", "ghost-cloud"],
|
|
10,
|
|
);
|
|
assert_failure(&out, "new-tab nonexistent session");
|
|
let v = parse_json(&out);
|
|
assert_eq!(v["command"], "browser.new-tab");
|
|
assert_error_envelope(&v, "SESSION_NOT_FOUND");
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_new_tab_nonexistent_session_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
|
|
let url = url_a();
|
|
let out = headless(
|
|
&["browser", "new-tab", &url, "--session", "ghost-cloud"],
|
|
10,
|
|
);
|
|
assert_failure(&out, "new-tab nonexistent session text");
|
|
let text = stdout_str(&out);
|
|
assert!(text.contains("SESSION_NOT_FOUND"), "text: {text}");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 5b: Cloud close-tab error cases
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_close_tab_nonexistent_session_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
|
|
let out = headless_json(
|
|
&[
|
|
"browser",
|
|
"close-tab",
|
|
"--session",
|
|
"ghost-cloud",
|
|
"--tab",
|
|
"FAKE-ID",
|
|
],
|
|
10,
|
|
);
|
|
assert_failure(&out, "close-tab nonexistent session");
|
|
let v = parse_json(&out);
|
|
assert_eq!(v["command"], "browser.close-tab");
|
|
assert_error_envelope(&v, "SESSION_NOT_FOUND");
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_close_tab_nonexistent_session_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
|
|
let out = headless(
|
|
&[
|
|
"browser",
|
|
"close-tab",
|
|
"--session",
|
|
"ghost-cloud",
|
|
"--tab",
|
|
"FAKE-ID",
|
|
],
|
|
10,
|
|
);
|
|
assert_failure(&out, "close-tab nonexistent session text");
|
|
let text = stdout_str(&out);
|
|
assert!(text.contains("SESSION_NOT_FOUND"));
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_close_tab_nonexistent_tab_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let out = headless_json(
|
|
&[
|
|
"browser",
|
|
"close-tab",
|
|
"--session",
|
|
&sid,
|
|
"--tab",
|
|
"FAKE-TARGET-ID",
|
|
],
|
|
10,
|
|
);
|
|
assert_failure(&out, "close-tab nonexistent tab");
|
|
let v = parse_json(&out);
|
|
assert_eq!(v["command"], "browser.close-tab");
|
|
assert_error_envelope(&v, "TAB_NOT_FOUND");
|
|
assert_context_with_session(&v, &sid);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_close_tab_nonexistent_tab_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let out = headless(
|
|
&[
|
|
"browser",
|
|
"close-tab",
|
|
"--session",
|
|
&sid,
|
|
"--tab",
|
|
"FAKE-TARGET-ID",
|
|
],
|
|
10,
|
|
);
|
|
assert_failure(&out, "close-tab nonexistent tab text");
|
|
let text = stdout_str(&out);
|
|
assert!(text.contains("TAB_NOT_FOUND"));
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_close_tab_double_close_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
let url = url_a();
|
|
let t2 = open_cloud_tab_json(&sid, &url);
|
|
|
|
let out = headless_json(
|
|
&["browser", "close-tab", "--session", &sid, "--tab", &t2],
|
|
15,
|
|
);
|
|
assert_success(&out, "first close");
|
|
|
|
let out = headless_json(
|
|
&["browser", "close-tab", "--session", &sid, "--tab", &t2],
|
|
10,
|
|
);
|
|
assert_failure(&out, "double close");
|
|
let v = parse_json(&out);
|
|
assert_error_envelope(&v, "TAB_NOT_FOUND");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 6: Cloud page operations (goto, snapshot, eval)
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_goto_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, tid) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let url = url_a();
|
|
let out = headless_json(
|
|
&["browser", "goto", &url, "--session", &sid, "--tab", &tid],
|
|
15,
|
|
);
|
|
assert_success(&out, "cloud goto");
|
|
let v = parse_json(&out);
|
|
assert_eq!(v["ok"], true);
|
|
assert!(v["error"].is_null());
|
|
assert_eq!(v["command"], "browser.goto");
|
|
assert_context_with_tab(&v, &sid, &tid);
|
|
assert_meta(&v);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_goto_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, tid) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let url = url_a();
|
|
let out = headless(
|
|
&["browser", "goto", &url, "--session", &sid, "--tab", &tid],
|
|
15,
|
|
);
|
|
assert_success(&out, "cloud goto text");
|
|
let text = stdout_str(&out);
|
|
assert!(!text.is_empty(), "goto text should not be empty: {text}");
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_snapshot_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, tid) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let url = url_a();
|
|
let _ = headless(
|
|
&["browser", "goto", &url, "--session", &sid, "--tab", &tid],
|
|
15,
|
|
);
|
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
|
|
|
let out = headless_json(
|
|
&["browser", "snapshot", "--session", &sid, "--tab", &tid],
|
|
15,
|
|
);
|
|
assert_success(&out, "cloud snapshot");
|
|
let v = parse_json(&out);
|
|
assert_eq!(v["ok"], true);
|
|
assert!(v["error"].is_null());
|
|
assert_eq!(v["command"], "browser.snapshot");
|
|
assert!(v["data"].is_object(), "snapshot data should be an object");
|
|
assert_context_with_tab(&v, &sid, &tid);
|
|
assert_meta(&v);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_snapshot_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, tid) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let url = url_a();
|
|
let _ = headless(
|
|
&["browser", "goto", &url, "--session", &sid, "--tab", &tid],
|
|
15,
|
|
);
|
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
|
|
|
let out = headless(
|
|
&["browser", "snapshot", "--session", &sid, "--tab", &tid],
|
|
15,
|
|
);
|
|
assert_success(&out, "cloud snapshot text");
|
|
let text = stdout_str(&out);
|
|
assert!(
|
|
!text.is_empty(),
|
|
"snapshot text should not be empty: {text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_eval_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, tid) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let out = headless_json(
|
|
&["browser", "eval", "2 + 2", "--session", &sid, "--tab", &tid],
|
|
10,
|
|
);
|
|
assert_success(&out, "cloud eval");
|
|
let v = parse_json(&out);
|
|
assert_eq!(v["ok"], true);
|
|
assert!(v["error"].is_null());
|
|
assert_eq!(v["command"], "browser.eval");
|
|
assert_eq!(
|
|
v["data"]["value"],
|
|
serde_json::json!(4),
|
|
"eval 2+2 should return typed 4"
|
|
);
|
|
assert_context_with_tab(&v, &sid, &tid);
|
|
assert_meta(&v);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_eval_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, tid) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let out = headless(
|
|
&["browser", "eval", "2 + 2", "--session", &sid, "--tab", &tid],
|
|
10,
|
|
);
|
|
assert_success(&out, "cloud eval text");
|
|
let text = stdout_str(&out);
|
|
assert!(!text.is_empty(), "eval text should not be empty: {text}");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 7: Cloud with multiple headers
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_start_with_multiple_headers_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_with_headers_json(
|
|
&ws_url,
|
|
&[
|
|
"Authorization:Bearer test-token-123",
|
|
"X-Api-Key:my-api-key",
|
|
],
|
|
);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let out = headless_json(&["browser", "status", "--session", &sid], 10);
|
|
assert_success(&out, "cloud status");
|
|
let v = parse_json(&out);
|
|
let session = &v["data"]["session"];
|
|
assert_eq!(session["mode"], "cloud");
|
|
assert!(
|
|
session.get("headers").is_none() || session["headers"].is_null(),
|
|
"status must not expose headers"
|
|
);
|
|
assert_meta(&v);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 8: Cloud session status and list-sessions
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_status_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let out = headless_json(&["browser", "status", "--session", &sid], 10);
|
|
assert_success(&out, "cloud status");
|
|
let v = parse_json(&out);
|
|
assert_eq!(v["ok"], true);
|
|
assert!(v["error"].is_null());
|
|
assert_eq!(v["command"], "browser.status");
|
|
assert_eq!(v["data"]["session"]["mode"], "cloud");
|
|
assert_eq!(v["data"]["session"]["status"], "running");
|
|
assert!(v["data"]["session"]["cdp_endpoint"].is_string());
|
|
assert!(v["data"]["tabs"].is_array());
|
|
assert_context_with_session(&v, &sid);
|
|
assert_meta(&v);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_status_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let out = headless(&["browser", "status", "--session", &sid], 10);
|
|
assert_success(&out, "cloud status text");
|
|
let text = stdout_str(&out);
|
|
assert!(text.contains("mode: cloud"), "should show mode: cloud");
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_list_sessions_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let out = headless_json(&["browser", "list-sessions"], 10);
|
|
assert_success(&out, "list-sessions");
|
|
let v = parse_json(&out);
|
|
assert_eq!(v["ok"], true);
|
|
assert!(v["error"].is_null());
|
|
assert!(v["context"].is_null());
|
|
let sessions = v["data"]["sessions"].as_array().expect("sessions array");
|
|
let our_session = sessions
|
|
.iter()
|
|
.find(|s| s["session_id"].as_str() == Some(sid.as_str()))
|
|
.expect("our cloud session should appear in list");
|
|
assert_eq!(our_session["mode"], "cloud");
|
|
assert!(our_session["cdp_endpoint"].is_string());
|
|
assert!(
|
|
our_session.get("headers").is_none() || our_session["headers"].is_null(),
|
|
"list-sessions must not expose headers"
|
|
);
|
|
assert_meta(&v);
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_list_sessions_text() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let out = headless(&["browser", "list-sessions"], 10);
|
|
assert_success(&out, "list-sessions text");
|
|
let text = stdout_str(&out);
|
|
assert!(text.contains(&sid), "should list cloud session id");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 9: Cloud close semantics — browser stays alive
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_close_does_not_kill_browser() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
|
|
let out = headless(&["browser", "close", "--session", &sid], 10);
|
|
assert_success(&out, &format!("close {sid}"));
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
let out = headless_json(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
&ws_url,
|
|
],
|
|
15,
|
|
);
|
|
assert_success(&out, "reconnect after close");
|
|
let v = parse_json(&out);
|
|
assert_cloud_session(&v);
|
|
let sid2 = v["data"]["session"]["session_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
let _guard = SessionGuard::new(&sid2);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 10: Connection drop (SoloEnv — kills external Chrome)
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_connection_drop_returns_error() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let env = SoloEnv::new();
|
|
let (mut chrome, ws_url) = launch_simulated_cloud();
|
|
|
|
let out = env.headless_json(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
&ws_url,
|
|
],
|
|
30,
|
|
);
|
|
assert_success(&out, "cloud start");
|
|
let v = parse_json(&out);
|
|
let sid = v["data"]["session"]["session_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
let tid = v["data"]["tab"]["tab_id"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.to_string();
|
|
|
|
let _ = chrome.child.kill();
|
|
let _ = chrome.child.wait();
|
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
|
|
|
let out = env.headless_json(
|
|
&["browser", "eval", "1+1", "--session", &sid, "--tab", &tid],
|
|
10,
|
|
);
|
|
let stdout = stdout_str(&out);
|
|
if stdout.is_empty() {
|
|
assert!(
|
|
!out.status.success(),
|
|
"command after disconnect should fail"
|
|
);
|
|
} else {
|
|
let v = parse_json(&out);
|
|
assert_eq!(v["ok"], false);
|
|
let code = v["error"]["code"].as_str().unwrap_or("");
|
|
assert!(
|
|
code == "CLOUD_CONNECTION_LOST" || code == "CDP_ERROR" || code == "EVAL_FAILED",
|
|
"expected cloud connection error, got: {code}"
|
|
);
|
|
}
|
|
|
|
let _ = env.headless(&["browser", "close", "--session", &sid], 5);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 11: Cloud restart preserves endpoint/headers
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_restart_preserves_endpoint_and_headers_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_with_headers_json(
|
|
&ws_url,
|
|
&["Authorization:Bearer my-token", "X-Custom:value"],
|
|
);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let out = headless_json(&["browser", "restart", "--session", &sid], 30);
|
|
assert_success(&out, "cloud restart");
|
|
let v = parse_json(&out);
|
|
assert_eq!(v["ok"], true);
|
|
assert_eq!(v["data"]["session"]["mode"], "cloud");
|
|
assert_eq!(v["data"]["session"]["status"], "running");
|
|
assert!(v["data"]["session"]["cdp_endpoint"].is_string());
|
|
assert_eq!(v["data"]["reopened"], true);
|
|
assert!(
|
|
v["data"]["session"].get("headers").is_none() || v["data"]["session"]["headers"].is_null(),
|
|
);
|
|
assert_meta(&v);
|
|
|
|
let tid = v["data"]["tab"]["tab_id"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.to_string();
|
|
if !tid.is_empty() {
|
|
let out = headless_json(
|
|
&["browser", "eval", "1+1", "--session", &sid, "--tab", &tid],
|
|
10,
|
|
);
|
|
assert_success(&out, "eval after restart");
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 12a: Daemon crash recovery — reconnect (SoloEnv)
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_crash_recovery_reconnect() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
// SoloEnv because we kill the daemon to simulate crash.
|
|
let env = SoloEnv::new();
|
|
let (mut chrome, ws_url) = launch_simulated_cloud();
|
|
|
|
let out = env.headless_json(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
&ws_url,
|
|
],
|
|
30,
|
|
);
|
|
assert_success(&out, "cloud start");
|
|
|
|
// Kill Chrome, then launch a fresh one on a new port.
|
|
let _ = chrome.child.kill();
|
|
let _ = chrome.child.wait();
|
|
let (_chrome2, ws_url2) = launch_simulated_cloud();
|
|
|
|
// Start a new session to the fresh endpoint — should work cleanly
|
|
// after the daemon auto-restarts.
|
|
let out = env.headless_json(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
&ws_url2,
|
|
],
|
|
30,
|
|
);
|
|
assert_success(&out, "start after daemon crash");
|
|
let v = parse_json(&out);
|
|
assert_cloud_session(&v);
|
|
let sid2 = v["data"]["session"]["session_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
let _ = env.headless(&["browser", "close", "--session", &sid2], 10);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 12b: Daemon crash recovery — endpoint gone (SoloEnv)
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_crash_recovery_endpoint_gone() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let env = SoloEnv::new();
|
|
let (mut chrome, ws_url) = launch_simulated_cloud();
|
|
|
|
let out = env.headless_json(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
&ws_url,
|
|
],
|
|
30,
|
|
);
|
|
assert_success(&out, "cloud start");
|
|
|
|
let _ = chrome.child.kill();
|
|
let _ = chrome.child.wait();
|
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
|
|
|
let out = env.headless_json(
|
|
&[
|
|
"browser",
|
|
"start",
|
|
"--mode",
|
|
"cloud",
|
|
"--cdp-endpoint",
|
|
&ws_url,
|
|
],
|
|
15,
|
|
);
|
|
// After killing Chrome, the CLI must either:
|
|
// (a) Return a connection error (CDP_CONNECTION_FAILED / CLOUD_CONNECTION_LOST), or
|
|
// (b) Exit non-zero (daemon itself may crash with no JSON output).
|
|
// It must NOT succeed with ok=true and a new functional session.
|
|
let stdout = stdout_str(&out);
|
|
if stdout.is_empty() {
|
|
// Daemon crashed — non-zero exit with no output is acceptable.
|
|
assert!(
|
|
!out.status.success(),
|
|
"empty stdout must come with non-zero exit"
|
|
);
|
|
} else {
|
|
let v = parse_json(&out);
|
|
if v["ok"].as_bool() == Some(true) {
|
|
// Reused existing session — verify it's actually dead by trying eval.
|
|
let sid = v["data"]["session"]["session_id"].as_str().unwrap_or("");
|
|
let tid = v["data"]["tab"]["tab_id"].as_str().unwrap_or("");
|
|
if !sid.is_empty() && !tid.is_empty() {
|
|
let eval_out = env.headless_json(
|
|
&["browser", "eval", "1+1", "--session", sid, "--tab", tid],
|
|
10,
|
|
);
|
|
// The eval should fail since Chrome is dead.
|
|
assert!(
|
|
!eval_out.status.success(),
|
|
"eval on dead cloud session should fail"
|
|
);
|
|
}
|
|
} else {
|
|
// JSON error — verify it's a connection error.
|
|
let code = v["error"]["code"].as_str().unwrap_or("");
|
|
assert!(
|
|
code == "CDP_CONNECTION_FAILED" || code == "CLOUD_CONNECTION_LOST",
|
|
"expected connection error, got: {code}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 13: Concurrent cloud operations
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_concurrent_eval_multi_tab() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, t1) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
let url = url_a();
|
|
let t2 = open_cloud_tab_json(&sid, &url);
|
|
|
|
let sid_a = sid.clone();
|
|
let t1_a = t1.clone();
|
|
let ha = std::thread::spawn(move || {
|
|
headless_json(
|
|
&[
|
|
"browser",
|
|
"eval",
|
|
"1+1",
|
|
"--session",
|
|
&sid_a,
|
|
"--tab",
|
|
&t1_a,
|
|
],
|
|
10,
|
|
)
|
|
});
|
|
let sid_b = sid.clone();
|
|
let t2_b = t2.clone();
|
|
let hb = std::thread::spawn(move || {
|
|
headless_json(
|
|
&[
|
|
"browser",
|
|
"eval",
|
|
"2+2",
|
|
"--session",
|
|
&sid_b,
|
|
"--tab",
|
|
&t2_b,
|
|
],
|
|
10,
|
|
)
|
|
});
|
|
|
|
let out_a = ha.join().unwrap();
|
|
let out_b = hb.join().unwrap();
|
|
assert_success(&out_a, "eval tab1");
|
|
assert_success(&out_b, "eval tab2");
|
|
|
|
let va = parse_json(&out_a);
|
|
let vb = parse_json(&out_b);
|
|
assert!(va["data"]["value"].as_str().unwrap_or("").contains('2'));
|
|
assert!(vb["data"]["value"].as_str().unwrap_or("").contains('4'));
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_concurrent_tab_create() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let urls = [url_a(), url_b(), url_c()];
|
|
let handles: Vec<_> = urls
|
|
.iter()
|
|
.map(|url| {
|
|
let sid = sid.clone();
|
|
let url = url.clone();
|
|
std::thread::spawn(move || {
|
|
headless_json(&["browser", "new-tab", &url, "--session", &sid], 15)
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
for h in handles {
|
|
let out = h.join().unwrap();
|
|
assert_success(&out, "concurrent new-tab");
|
|
}
|
|
|
|
let out = headless_json(&["browser", "list-tabs", "--session", &sid], 10);
|
|
let v = parse_json(&out);
|
|
assert!(v["data"]["total_tabs"].as_u64().unwrap_or(0) >= 4);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 14: Cloud double-close session
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_double_close_session_json() {
|
|
if skip() {
|
|
return;
|
|
}
|
|
let (_chrome, ws_url) = launch_simulated_cloud();
|
|
let (sid, _) = start_cloud_json(&ws_url);
|
|
|
|
let out = headless_json(&["browser", "close", "--session", &sid], 10);
|
|
assert_success(&out, "first close");
|
|
|
|
let out = headless_json(&["browser", "close", "--session", &sid], 10);
|
|
assert_failure(&out, "double close");
|
|
let v = parse_json(&out);
|
|
assert_error_envelope(&v, "SESSION_NOT_FOUND");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Group 15: Real cloud browser tests (hyperbrowse)
|
|
// Gated by ACTIONBOOK_CLOUD_CDP_ENDPOINT env var
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn cloud_real_start_and_close() {
|
|
if skip() || skip_cloud_real() {
|
|
return;
|
|
}
|
|
|
|
let (endpoint, headers) = cloud_endpoint().unwrap();
|
|
let mut args = vec![
|
|
"browser".to_string(),
|
|
"start".to_string(),
|
|
"--mode".to_string(),
|
|
"cloud".to_string(),
|
|
"--cdp-endpoint".to_string(),
|
|
endpoint.clone(),
|
|
];
|
|
for h in &headers {
|
|
args.push("--header".to_string());
|
|
args.push(h.clone());
|
|
}
|
|
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
|
|
|
let out = headless_json(&args_ref, 30);
|
|
assert_success(&out, "real cloud start");
|
|
let v = parse_json(&out);
|
|
assert_cloud_session(&v);
|
|
|
|
let sid = v["data"]["session"]["session_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
let _guard = SessionGuard::new(&sid);
|
|
let tid = v["data"]["tab"]["tab_id"].as_str().unwrap().to_string();
|
|
|
|
let out = headless_json(
|
|
&[
|
|
"browser",
|
|
"eval",
|
|
"navigator.userAgent",
|
|
"--session",
|
|
&sid,
|
|
"--tab",
|
|
&tid,
|
|
],
|
|
15,
|
|
);
|
|
assert_success(&out, "real cloud eval");
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_real_goto_and_snapshot() {
|
|
if skip() || skip_cloud_real() {
|
|
return;
|
|
}
|
|
|
|
let url = url_a();
|
|
let (endpoint, headers) = cloud_endpoint().unwrap();
|
|
let mut args = vec![
|
|
"browser".to_string(),
|
|
"start".to_string(),
|
|
"--mode".to_string(),
|
|
"cloud".to_string(),
|
|
"--cdp-endpoint".to_string(),
|
|
endpoint.clone(),
|
|
"--open-url".to_string(),
|
|
url,
|
|
];
|
|
for h in &headers {
|
|
args.push("--header".to_string());
|
|
args.push(h.clone());
|
|
}
|
|
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
|
|
|
let out = headless_json(&args_ref, 30);
|
|
assert_success(&out, "real cloud start with url");
|
|
let v = parse_json(&out);
|
|
let sid = v["data"]["session"]["session_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
let _guard = SessionGuard::new(&sid);
|
|
let tid = v["data"]["tab"]["tab_id"].as_str().unwrap().to_string();
|
|
|
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
|
|
|
let out = headless_json(
|
|
&["browser", "snapshot", "--session", &sid, "--tab", &tid],
|
|
15,
|
|
);
|
|
assert_success(&out, "real cloud snapshot");
|
|
}
|
|
|
|
#[test]
|
|
fn cloud_real_tab_management() {
|
|
if skip() || skip_cloud_real() {
|
|
return;
|
|
}
|
|
|
|
let (endpoint, headers) = cloud_endpoint().unwrap();
|
|
let mut args = vec![
|
|
"browser".to_string(),
|
|
"start".to_string(),
|
|
"--mode".to_string(),
|
|
"cloud".to_string(),
|
|
"--cdp-endpoint".to_string(),
|
|
endpoint.clone(),
|
|
];
|
|
for h in &headers {
|
|
args.push("--header".to_string());
|
|
args.push(h.clone());
|
|
}
|
|
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
|
|
|
let out = headless_json(&args_ref, 30);
|
|
assert_success(&out, "real cloud start");
|
|
let v = parse_json(&out);
|
|
let sid = v["data"]["session"]["session_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
let _guard = SessionGuard::new(&sid);
|
|
|
|
let url = url_a();
|
|
let out = headless_json(&["browser", "new-tab", &url, "--session", &sid], 15);
|
|
assert_success(&out, "real cloud new-tab");
|
|
let v = parse_json(&out);
|
|
let t2 = v["data"]["tab"]["tab_id"].as_str().unwrap().to_string();
|
|
|
|
let out = headless_json(&["browser", "list-tabs", "--session", &sid], 10);
|
|
assert_success(&out, "real cloud list-tabs");
|
|
let v = parse_json(&out);
|
|
assert!(v["data"]["total_tabs"].as_u64().unwrap_or(0) >= 2);
|
|
|
|
let out = headless_json(
|
|
&["browser", "close-tab", "--session", &sid, "--tab", &t2],
|
|
15,
|
|
);
|
|
assert_success(&out, "real cloud close-tab");
|
|
}
|