Files
mcfn.feng c18c65c280 [packages/cli-v2]refactor: improve E2E test harness for reliability and maintainability
- Extract common helpers to harness.rs (assert_meta, assert_error_envelope,
  assert_tab_id, start_session, close_session, new_tab_json, etc.)
- Add local HTTP server to eliminate external network dependency
- Introduce SoloEnv for tests needing isolated ACTIONBOOK_HOME
- Replace fixed sleep(500ms) with per-test session isolation
- Fix TempDir leak in cloud_mode (ChromeGuard now owns TempDir)
- Add PID to unique_session IDs to prevent cross-run collisions
- HTTP server: thread-per-connection + Connection: close header
- Restore all cloud_mode tests (groups 5b-15) that were missing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:45:01 +08:00

886 lines
23 KiB
Rust

//! Browser navigation E2E tests: goto, back, forward, reload.
//!
//! All navigation commands are Tab-level: require `--session <SID> --tab <TID>`.
//! Tests are strict per api-reference.md section 9.
//!
//! Uses local HTTP server from harness — no external network dependency.
use crate::harness::{
SessionGuard, assert_error_envelope, assert_failure, assert_meta, assert_success, headless,
headless_json, parse_json, skip, start_session, stdout_str, url_a, url_b,
};
// ── Helpers ───────────────────────────────────────────────────────────
/// Assert navigation data fields per section 9 contract.
fn assert_nav_data(v: &serde_json::Value, expected_kind: &str, has_requested_url: bool) {
let data = &v["data"];
assert_eq!(
data["kind"], expected_kind,
"data.kind must be '{expected_kind}'"
);
if has_requested_url {
assert!(
data["requested_url"].is_string(),
"data.requested_url must be a string for goto"
);
assert!(
!data["requested_url"].as_str().unwrap().is_empty(),
"data.requested_url must not be empty"
);
} else {
assert!(
data["requested_url"].is_null(),
"data.requested_url must be null for back/forward/reload (got {:?})",
data["requested_url"]
);
}
assert!(
data["from_url"].is_string(),
"data.from_url must be a string"
);
assert!(data["to_url"].is_string(), "data.to_url must be a string");
assert!(data["title"].is_string(), "data.title must be a string");
}
// ===========================================================================
// Group 1: goto — Happy Path
// ===========================================================================
#[test]
fn nav_goto_json() {
if skip() {
return;
}
let (sid, tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let out = headless_json(
&["browser", "goto", &url_b, "--session", &sid, "--tab", &tid],
30,
);
assert_success(&out, "goto json");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["command"], "browser.goto");
assert!(v["error"].is_null());
assert!(v["context"].is_object(), "context must be present");
assert_eq!(v["context"]["session_id"], sid);
assert_eq!(v["context"]["tab_id"], tid);
assert!(
v["context"]["url"].is_string(),
"context.url must be updated after goto"
);
assert!(
v["context"]["title"].is_string(),
"context.title must be updated after goto"
);
assert_nav_data(&v, "goto", true);
assert_eq!(v["data"]["requested_url"], url_b);
assert!(
v["data"]["to_url"].as_str().unwrap().contains("page-b"),
"to_url should contain page-b"
);
assert_meta(&v);
}
#[test]
fn nav_goto_text() {
if skip() {
return;
}
let (sid, tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let out = headless(
&["browser", "goto", &url_b, "--session", &sid, "--tab", &tid],
30,
);
assert_success(&out, "goto text");
let text = stdout_str(&out);
assert!(
text.contains(&format!("[{sid} {tid}]")),
"header must contain [session_id tab_id]: got {text}"
);
assert!(
text.contains("ok browser.goto"),
"must contain ok browser.goto"
);
assert!(text.contains("title:"), "must contain title:");
}
#[test]
fn nav_goto_context_url_updated() {
if skip() {
return;
}
let (sid, tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let out = headless_json(
&["browser", "goto", &url_b, "--session", &sid, "--tab", &tid],
30,
);
assert_success(&out, "goto url_b");
let v = parse_json(&out);
let ctx_url = v["context"]["url"].as_str().unwrap_or("");
assert!(
ctx_url.contains("page-b"),
"context.url must be updated to destination: got '{ctx_url}'"
);
let ctx_title = v["context"]["title"].as_str().unwrap_or("");
assert!(
!ctx_title.is_empty(),
"context.title must be updated after goto: got empty string"
);
}
// ===========================================================================
// Group 2: goto — Error Paths
// ===========================================================================
#[test]
fn nav_goto_navigation_failed_json() {
if skip() {
return;
}
let (sid, tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless_json(
&[
"browser",
"goto",
"invalidscheme://this-should-fail",
"--session",
&sid,
"--tab",
&tid,
],
15,
);
assert_failure(&out, "goto invalid scheme");
let v = parse_json(&out);
assert_eq!(v["command"], "browser.goto");
assert_error_envelope(&v, "NAVIGATION_FAILED");
assert!(
v["context"].is_object(),
"context must be present when session and tab are found"
);
assert_eq!(v["context"]["session_id"], sid);
assert_eq!(v["context"]["tab_id"], tid);
}
#[test]
fn nav_goto_session_not_found_json() {
if skip() {
return;
}
let out = headless_json(
&[
"browser",
"goto",
&url_b(),
"--session",
"nonexistent-sid",
"--tab",
"any-tab",
],
10,
);
assert_failure(&out, "goto nonexistent session");
let v = parse_json(&out);
assert_eq!(v["command"], "browser.goto");
assert_error_envelope(&v, "SESSION_NOT_FOUND");
assert!(
v["context"].is_null(),
"context must be null when session not found"
);
}
#[test]
fn nav_goto_session_not_found_text() {
if skip() {
return;
}
let out = headless(
&[
"browser",
"goto",
&url_b(),
"--session",
"nonexistent-sid",
"--tab",
"any-tab",
],
10,
);
assert_failure(&out, "goto nonexistent session text");
let text = stdout_str(&out);
assert!(
text.contains("error SESSION_NOT_FOUND:"),
"text must contain error SESSION_NOT_FOUND: got {text}"
);
}
#[test]
fn nav_goto_tab_not_found_json() {
if skip() {
return;
}
let (sid, _tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless_json(
&[
"browser",
"goto",
&url_b(),
"--session",
&sid,
"--tab",
"nonexistent-tab-id",
],
10,
);
assert_failure(&out, "goto nonexistent tab");
let v = parse_json(&out);
assert_eq!(v["command"], "browser.goto");
assert_error_envelope(&v, "TAB_NOT_FOUND");
assert!(
v["context"].is_object(),
"context must be present when session found"
);
assert_eq!(v["context"]["session_id"], sid);
}
#[test]
fn nav_goto_tab_not_found_text() {
if skip() {
return;
}
let (sid, _tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless(
&[
"browser",
"goto",
&url_b(),
"--session",
&sid,
"--tab",
"nonexistent-tab-id",
],
10,
);
assert_failure(&out, "goto nonexistent tab text");
let text = stdout_str(&out);
assert!(
text.contains("error TAB_NOT_FOUND:"),
"text must contain error TAB_NOT_FOUND: got {text}"
);
}
// ===========================================================================
// Group 3: back — Happy Path
// ===========================================================================
#[test]
fn nav_back_json() {
if skip() {
return;
}
let (sid, tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let out = headless_json(
&["browser", "goto", &url_b, "--session", &sid, "--tab", &tid],
30,
);
assert_success(&out, "goto url_b before back");
let out = headless_json(&["browser", "back", "--session", &sid, "--tab", &tid], 30);
assert_success(&out, "back json");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["command"], "browser.back");
assert!(v["error"].is_null());
assert!(v["context"].is_object(), "context must be present");
assert_eq!(v["context"]["session_id"], sid);
assert_eq!(v["context"]["tab_id"], tid);
assert!(
v["context"]["url"].is_string(),
"context.url must be updated after back"
);
assert!(
!v["context"]["title"].as_str().unwrap_or("").is_empty(),
"context.title must be updated after back"
);
assert_nav_data(&v, "back", false);
assert!(
v["data"]["to_url"]
.as_str()
.unwrap_or("")
.contains("page-a"),
"back to_url should be the previous URL (page-a)"
);
assert_meta(&v);
}
#[test]
fn nav_back_text() {
if skip() {
return;
}
let (sid, tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let out = headless_json(
&["browser", "goto", &url_b, "--session", &sid, "--tab", &tid],
30,
);
assert_success(&out, "goto url_b before back text");
let out = headless(&["browser", "back", "--session", &sid, "--tab", &tid], 30);
assert_success(&out, "back text");
let text = stdout_str(&out);
assert!(
text.contains(&format!("[{sid} {tid}]")),
"header must contain [session_id tab_id]: got {text}"
);
assert!(
text.contains("ok browser.back"),
"must contain ok browser.back"
);
assert!(text.contains("title:"), "must contain title:");
}
// ===========================================================================
// Group 4: back — Error Paths
// ===========================================================================
#[test]
fn nav_back_session_not_found_json() {
if skip() {
return;
}
let out = headless_json(
&[
"browser",
"back",
"--session",
"nonexistent-sid",
"--tab",
"any-tab",
],
10,
);
assert_failure(&out, "back nonexistent session");
let v = parse_json(&out);
assert_eq!(v["command"], "browser.back");
assert_error_envelope(&v, "SESSION_NOT_FOUND");
assert!(
v["context"].is_null(),
"context must be null when session not found"
);
}
#[test]
fn nav_back_tab_not_found_json() {
if skip() {
return;
}
let (sid, _tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless_json(
&[
"browser",
"back",
"--session",
&sid,
"--tab",
"nonexistent-tab-id",
],
10,
);
assert_failure(&out, "back nonexistent tab");
let v = parse_json(&out);
assert_eq!(v["command"], "browser.back");
assert_error_envelope(&v, "TAB_NOT_FOUND");
assert!(
v["context"].is_object(),
"context must be present when session found"
);
assert_eq!(v["context"]["session_id"], sid);
}
// ===========================================================================
// Group 5: forward — Happy Path
// ===========================================================================
#[test]
fn nav_forward_json() {
if skip() {
return;
}
let (sid, tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let out = headless_json(
&["browser", "goto", &url_b, "--session", &sid, "--tab", &tid],
30,
);
assert_success(&out, "goto url_b");
let out = headless_json(&["browser", "back", "--session", &sid, "--tab", &tid], 30);
assert_success(&out, "back to url_a");
let out = headless_json(
&["browser", "forward", "--session", &sid, "--tab", &tid],
30,
);
assert_success(&out, "forward json");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["command"], "browser.forward");
assert!(v["error"].is_null());
assert!(v["context"].is_object(), "context must be present");
assert_eq!(v["context"]["session_id"], sid);
assert_eq!(v["context"]["tab_id"], tid);
assert!(
v["context"]["url"].is_string(),
"context.url must be updated after forward"
);
assert!(
!v["context"]["title"].as_str().unwrap_or("").is_empty(),
"context.title must be updated after forward"
);
assert_nav_data(&v, "forward", false);
assert!(
v["data"]["to_url"]
.as_str()
.unwrap_or("")
.contains("page-b"),
"forward to_url should be the next URL (page-b)"
);
assert_meta(&v);
}
#[test]
fn nav_forward_text() {
if skip() {
return;
}
let (sid, tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let out = headless_json(
&["browser", "goto", &url_b, "--session", &sid, "--tab", &tid],
30,
);
assert_success(&out, "goto url_b");
let out = headless_json(&["browser", "back", "--session", &sid, "--tab", &tid], 30);
assert_success(&out, "back to url_a");
let out = headless(
&["browser", "forward", "--session", &sid, "--tab", &tid],
30,
);
assert_success(&out, "forward text");
let text = stdout_str(&out);
assert!(
text.contains(&format!("[{sid} {tid}]")),
"header must contain [session_id tab_id]: got {text}"
);
assert!(
text.contains("ok browser.forward"),
"must contain ok browser.forward"
);
assert!(text.contains("title:"), "must contain title:");
}
// ===========================================================================
// Group 6: forward — Error Paths
// ===========================================================================
#[test]
fn nav_forward_session_not_found_json() {
if skip() {
return;
}
let out = headless_json(
&[
"browser",
"forward",
"--session",
"nonexistent-sid",
"--tab",
"any-tab",
],
10,
);
assert_failure(&out, "forward nonexistent session");
let v = parse_json(&out);
assert_eq!(v["command"], "browser.forward");
assert_error_envelope(&v, "SESSION_NOT_FOUND");
assert!(
v["context"].is_null(),
"context must be null when session not found"
);
}
#[test]
fn nav_forward_tab_not_found_json() {
if skip() {
return;
}
let (sid, _tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless_json(
&[
"browser",
"forward",
"--session",
&sid,
"--tab",
"nonexistent-tab-id",
],
10,
);
assert_failure(&out, "forward nonexistent tab");
let v = parse_json(&out);
assert_eq!(v["command"], "browser.forward");
assert_error_envelope(&v, "TAB_NOT_FOUND");
assert!(
v["context"].is_object(),
"context must be present when session found"
);
assert_eq!(v["context"]["session_id"], sid);
}
// ===========================================================================
// Group 7: reload — Happy Path
// ===========================================================================
#[test]
fn nav_reload_json() {
if skip() {
return;
}
let (sid, tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless_json(&["browser", "reload", "--session", &sid, "--tab", &tid], 30);
assert_success(&out, "reload json");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["command"], "browser.reload");
assert!(v["error"].is_null());
assert!(v["context"].is_object(), "context must be present");
assert_eq!(v["context"]["session_id"], sid);
assert_eq!(v["context"]["tab_id"], tid);
assert!(
v["context"]["url"].is_string(),
"context.url must be present after reload"
);
assert!(
!v["context"]["title"].as_str().unwrap_or("").is_empty(),
"context.title must be updated after reload"
);
assert_nav_data(&v, "reload", false);
assert_eq!(
v["data"]["from_url"], v["data"]["to_url"],
"reload: from_url must equal to_url"
);
assert_meta(&v);
}
#[test]
fn nav_reload_text() {
if skip() {
return;
}
let (sid, tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless(&["browser", "reload", "--session", &sid, "--tab", &tid], 30);
assert_success(&out, "reload text");
let text = stdout_str(&out);
assert!(
text.contains(&format!("[{sid} {tid}]")),
"header must contain [session_id tab_id]: got {text}"
);
assert!(
text.contains("ok browser.reload"),
"must contain ok browser.reload"
);
assert!(text.contains("title:"), "must contain title:");
}
#[test]
fn nav_reload_preserves_url() {
if skip() {
return;
}
let (sid, tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let out = headless_json(
&["browser", "goto", &url_b, "--session", &sid, "--tab", &tid],
30,
);
assert_success(&out, "goto url_b before reload");
let out = headless_json(&["browser", "reload", "--session", &sid, "--tab", &tid], 30);
assert_success(&out, "reload after goto");
let v = parse_json(&out);
assert!(
v["data"]["to_url"]
.as_str()
.unwrap_or("")
.contains("page-b"),
"reload to_url must stay on current page (page-b)"
);
}
// ===========================================================================
// Group 8: reload — Error Paths
// ===========================================================================
#[test]
fn nav_reload_session_not_found_json() {
if skip() {
return;
}
let out = headless_json(
&[
"browser",
"reload",
"--session",
"nonexistent-sid",
"--tab",
"any-tab",
],
10,
);
assert_failure(&out, "reload nonexistent session");
let v = parse_json(&out);
assert_eq!(v["command"], "browser.reload");
assert_error_envelope(&v, "SESSION_NOT_FOUND");
assert!(
v["context"].is_null(),
"context must be null when session not found"
);
}
#[test]
fn nav_reload_tab_not_found_json() {
if skip() {
return;
}
let (sid, _tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless_json(
&[
"browser",
"reload",
"--session",
&sid,
"--tab",
"nonexistent-tab-id",
],
10,
);
assert_failure(&out, "reload nonexistent tab");
let v = parse_json(&out);
assert_eq!(v["command"], "browser.reload");
assert_error_envelope(&v, "TAB_NOT_FOUND");
assert!(
v["context"].is_object(),
"context must be present when session found"
);
assert_eq!(v["context"]["session_id"], sid);
}
// ===========================================================================
// Group 9: NAVIGATION_FAILED — back/forward/reload
// ===========================================================================
#[test]
fn nav_back_navigation_failed_json() {
if skip() {
return;
}
let (sid, tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless_json(&["browser", "back", "--session", &sid, "--tab", &tid], 15);
assert_failure(&out, "back with no history");
let v = parse_json(&out);
assert_eq!(v["command"], "browser.back");
assert_error_envelope(&v, "NAVIGATION_FAILED");
assert!(
v["context"].is_object(),
"context present when session/tab found"
);
assert_eq!(v["context"]["session_id"], sid);
assert_eq!(v["context"]["tab_id"], tid);
}
#[test]
fn nav_forward_navigation_failed_json() {
if skip() {
return;
}
let (sid, tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless_json(
&["browser", "forward", "--session", &sid, "--tab", &tid],
15,
);
assert_failure(&out, "forward with no history");
let v = parse_json(&out);
assert_eq!(v["command"], "browser.forward");
assert_error_envelope(&v, "NAVIGATION_FAILED");
assert!(
v["context"].is_object(),
"context present when session/tab found"
);
assert_eq!(v["context"]["session_id"], sid);
assert_eq!(v["context"]["tab_id"], tid);
}
#[test]
fn nav_reload_navigation_failed_json() {
if skip() {
return;
}
let (sid, tid) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let _goto_out = headless_json(
&[
"browser",
"goto",
"invalidscheme://this-should-fail",
"--session",
&sid,
"--tab",
&tid,
],
15,
);
let out = headless_json(&["browser", "reload", "--session", &sid, "--tab", &tid], 15);
if out.status.success() {
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["command"], "browser.reload");
} else {
let v = parse_json(&out);
assert_eq!(v["command"], "browser.reload");
assert_error_envelope(&v, "NAVIGATION_FAILED");
}
}
// ===========================================================================
// Group 10: Missing Args (--session and --tab required)
// ===========================================================================
#[test]
fn nav_goto_missing_session_arg() {
if skip() {
return;
}
let out = headless_json(&["browser", "goto", &url_b(), "--tab", "some-tab"], 10);
assert_failure(&out, "goto missing --session");
}
#[test]
fn nav_goto_missing_tab_arg() {
if skip() {
return;
}
let out = headless_json(
&["browser", "goto", &url_b(), "--session", "some-session"],
10,
);
assert_failure(&out, "goto missing --tab");
}
#[test]
fn nav_back_missing_session_arg() {
if skip() {
return;
}
let out = headless_json(&["browser", "back", "--tab", "some-tab"], 10);
assert_failure(&out, "back missing --session");
}
#[test]
fn nav_reload_missing_tab_arg() {
if skip() {
return;
}
let out = headless_json(&["browser", "reload", "--session", "some-session"], 10);
assert_failure(&out, "reload missing --tab");
}