Files
actionbook__actionbook/packages/cli/tests/e2e/tab_management.rs
mcfn 6da55f0d94 Merge branch 'main' into feat/multi-click
Resolve version conflicts (take main's 1.1.0), apply cargo fmt.
2026-04-09 13:38:32 +08:00

1258 lines
35 KiB
Rust

//! Browser tab management E2E tests: list-tabs, new-tab, close-tab.
//!
//! Each test is self-contained: start -> operate -> assert -> close.
//! Covers BOTH JSON and text output.
//!
//! Uses local HTTP server from harness — no external network dependency.
use crate::harness::{
SessionGuard, assert_context_object, assert_context_with_session, assert_error_envelope,
assert_failure, assert_meta, assert_native_tab_id, assert_success, assert_tab_id, headless,
headless_json, new_tab_json, parse_json, skip, start_named_session, start_session, stdout_str,
unique_session, url_a, url_b, url_c,
};
// ===========================================================================
// Group 1: list-tabs — Basic
// ===========================================================================
#[test]
fn tab_list_tabs_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless_json(&["browser", "list-tabs", "--session", &sid], 10);
assert_success(&out, "list-tabs json");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["command"], "browser list-tabs");
assert!(v["error"].is_null());
assert_context_object(&v);
assert_eq!(v["context"]["session_id"], sid);
assert!(v["data"]["total_tabs"].as_u64().unwrap_or(0) >= 1);
let tabs = v["data"]["tabs"].as_array().expect("tabs array");
let tab = &tabs[0];
assert_tab_id(&tab["tab_id"]);
assert_native_tab_id(&tab["native_tab_id"]);
assert_ne!(tab["tab_id"], tab["native_tab_id"]);
assert!(tab["url"].is_string());
assert!(tab["title"].is_string());
assert_meta(&v);
}
#[test]
fn tab_list_tabs_text() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless(&["browser", "list-tabs", "--session", &sid], 10);
assert_success(&out, "list-tabs text");
let text = stdout_str(&out);
assert!(text.contains(&format!("[{sid}]")));
assert!(text.contains("tab"));
}
#[test]
fn tab_list_tabs_after_new_tab_json() {
if skip() {
return;
}
let (sid, t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let t2 = new_tab_json(&sid, &url_b());
let out = headless_json(&["browser", "list-tabs", "--session", &sid], 10);
assert_success(&out, "list-tabs after new-tab");
let v = parse_json(&out);
assert_eq!(v["data"]["total_tabs"], serde_json::json!(2));
let tabs = v["data"]["tabs"].as_array().unwrap();
let ids: Vec<&str> = tabs.iter().filter_map(|t| t["tab_id"].as_str()).collect();
assert!(ids.contains(&t1.as_str()), "should have t1");
assert!(ids.contains(&t2.as_str()), "should have t2");
for tab in tabs {
assert_tab_id(&tab["tab_id"]);
assert_native_tab_id(&tab["native_tab_id"]);
assert_ne!(tab["tab_id"], tab["native_tab_id"]);
assert!(tab["url"].is_string());
assert!(tab["title"].is_string());
}
}
#[test]
fn tab_list_tabs_after_new_tab_text() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let _t2 = new_tab_json(&sid, &url_b());
let out = headless(&["browser", "list-tabs", "--session", &sid], 10);
assert_success(&out, "list-tabs after new-tab text");
let text = stdout_str(&out);
assert!(text.contains(&format!("[{sid}]")));
assert!(text.contains("2"));
}
// ===========================================================================
// Group 2: new-tab — Basic
// ===========================================================================
#[test]
fn tab_new_tab_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let out = headless_json(&["browser", "new-tab", &url_b, "--session", &sid], 30);
assert_success(&out, "new-tab json");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["command"], "browser new-tab");
assert!(v["error"].is_null());
assert_context_object(&v);
assert_eq!(v["context"]["session_id"], sid);
assert_tab_id(&v["context"]["tab_id"]);
let tab = &v["data"]["tab"];
assert_tab_id(&tab["tab_id"]);
assert_native_tab_id(&tab["native_tab_id"]);
assert_ne!(tab["tab_id"], tab["native_tab_id"]);
assert!(tab["url"].is_string());
assert!(tab["title"].is_string());
assert_eq!(v["data"]["created"], true);
assert_eq!(v["data"]["new_window"], false);
assert_meta(&v);
}
#[test]
fn tab_new_tab_text() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let out = headless(&["browser", "new-tab", &url_b, "--session", &sid], 30);
assert_success(&out, "new-tab text");
let text = stdout_str(&out);
assert!(
text.contains(&format!("[{sid}")),
"header should contain session_id"
);
assert!(text.contains("ok browser new-tab"));
assert!(text.contains("title:"));
}
#[test]
fn tab_new_tab_sequential_ids_json() {
if skip() {
return;
}
let (sid, t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let t2 = new_tab_json(&sid, &url_b());
let t3 = new_tab_json(&sid, &url_c());
assert_eq!(t1, "t1");
assert_eq!(t2, "t2");
assert_eq!(t3, "t3");
let out = headless_json(&["browser", "list-tabs", "--session", &sid], 10);
assert_success(&out, "list-tabs 3 tabs");
let v = parse_json(&out);
assert_eq!(v["data"]["total_tabs"], serde_json::json!(3));
}
#[test]
fn tab_new_tab_alias_open_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let out = headless_json(&["browser", "open", &url_b, "--session", &sid], 30);
assert_success(&out, "browser open alias");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["command"], "browser new-tab");
assert!(v["error"].is_null());
assert_context_object(&v);
assert_eq!(v["context"]["session_id"], sid);
let tab = &v["data"]["tab"];
assert_tab_id(&tab["tab_id"]);
assert_native_tab_id(&tab["native_tab_id"]);
assert_ne!(tab["tab_id"], tab["native_tab_id"]);
assert_eq!(v["data"]["created"], true);
assert_eq!(v["data"]["new_window"], false);
assert_meta(&v);
}
#[test]
fn tab_new_tab_multiple_urls_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let url_c = url_c();
let out = headless_json(
&["browser", "new-tab", &url_b, &url_c, "--session", &sid],
30,
);
assert_success(&out, "new-tab multiple urls json");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["command"], "browser new-tab");
assert_context_with_session(&v, &sid);
assert!(v["context"]["tab_id"].is_null());
assert_eq!(v["data"]["requested_urls"], serde_json::json!(2));
assert_eq!(v["data"]["opened_tabs"], serde_json::json!(2));
assert_eq!(v["data"]["failed_urls"], serde_json::json!(0));
let tabs = v["data"]["tabs"].as_array().expect("tabs array");
assert_eq!(tabs.len(), 2);
for tab in tabs {
assert_tab_id(&tab["tab_id"]);
assert_native_tab_id(&tab["native_tab_id"]);
assert!(tab["url"].is_string());
}
}
#[test]
fn tab_new_tab_multiple_urls_text() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let url_c = url_c();
let out = headless(
&["browser", "new-tab", &url_b, &url_c, "--session", &sid],
30,
);
assert_success(&out, "new-tab multiple urls text");
let text = stdout_str(&out);
assert!(text.contains(&format!("2/2 tabs opened in session {sid}")));
assert!(text.contains(&format!("[{sid} t2]")));
assert!(text.contains(&format!("[{sid} t3]")));
}
#[test]
fn tab_new_tab_multiple_urls_with_tab_aliases_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let url_c = url_c();
let out = headless_json(
&[
"browser",
"new-tab",
&url_b,
&url_c,
"--session",
&sid,
"--tab",
"inbox",
"--tab",
"docs",
],
30,
);
assert_success(&out, "new-tab multiple urls with tab aliases json");
let v = parse_json(&out);
let tabs = v["data"]["tabs"].as_array().expect("tabs array");
assert_eq!(tabs.len(), 2);
assert_eq!(tabs[0]["tab_id"], serde_json::json!("inbox"));
assert_eq!(tabs[1]["tab_id"], serde_json::json!("docs"));
}
#[test]
fn tab_new_tab_multiple_urls_with_tab_alias_count_mismatch_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let url_c = url_c();
let out = headless_json(
&[
"browser",
"new-tab",
&url_b,
&url_c,
"--session",
&sid,
"--tab",
"inbox",
],
30,
);
assert_failure(&out, "new-tab multiple urls tab alias count mismatch");
let v = parse_json(&out);
assert_eq!(v["error"]["code"], "INVALID_ARGUMENT");
}
#[test]
fn tab_new_tab_multiple_urls_partial_failure_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let out = headless_json(
&[
"browser",
"new-tab",
&url_b,
"javascript:alert(1)",
"--session",
&sid,
],
30,
);
assert_failure(&out, "new-tab multiple urls partial failure json");
let v = parse_json(&out);
assert_eq!(v["command"], "browser new-tab");
assert_eq!(v["ok"], false);
assert_eq!(v["error"]["code"], "PARTIAL_FAILURE");
assert_context_with_session(&v, &sid);
assert_eq!(
v["error"]["details"]["requested_urls"],
serde_json::json!(2)
);
assert_eq!(v["error"]["details"]["opened_tabs"], serde_json::json!(1));
assert_eq!(v["error"]["details"]["failed_urls"], serde_json::json!(1));
assert_eq!(
v["error"]["details"]["failures"][0]["code"],
serde_json::json!("INVALID_ARGUMENT")
);
}
#[test]
fn tab_new_tab_multiple_urls_partial_failure_text() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let url_b = url_b();
let out = headless(
&[
"browser",
"new-tab",
&url_b,
"javascript:alert(1)",
"--session",
&sid,
],
30,
);
assert_failure(&out, "new-tab multiple urls partial failure text");
let text = stdout_str(&out);
assert!(text.contains(&format!("1/2 tabs opened in session {sid}")));
assert!(text.contains("[failed] javascript:alert(1) - INVALID_ARGUMENT:"));
}
// ===========================================================================
// Group 3: close-tab — Basic
// ===========================================================================
#[test]
fn tab_close_tab_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let t2 = new_tab_json(&sid, &url_b());
assert_eq!(t2, "t2");
let out = headless_json(
&["browser", "close-tab", "--session", &sid, "--tab", "t2"],
30,
);
assert_success(&out, "close-tab json");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["command"], "browser close-tab");
assert!(v["error"].is_null());
assert_context_object(&v);
assert_eq!(v["context"]["session_id"], sid);
assert_eq!(v["context"]["tab_id"], t2);
assert_eq!(v["data"]["closed_tab_id"], t2);
assert_meta(&v);
}
#[test]
fn tab_close_tab_text() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let t2 = new_tab_json(&sid, &url_b());
let out = headless(
&["browser", "close-tab", "--session", &sid, "--tab", &t2],
30,
);
assert_success(&out, "close-tab text");
let text = stdout_str(&out);
assert!(text.contains(&format!("[{sid}")));
assert!(text.contains("ok browser close-tab"));
}
#[test]
fn tab_close_tab_then_list_json() {
if skip() {
return;
}
let (sid, t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let t2 = new_tab_json(&sid, &url_b());
let t3 = new_tab_json(&sid, &url_c());
let out = headless(
&["browser", "close-tab", "--session", &sid, "--tab", &t2],
30,
);
assert_success(&out, "close t2");
let out = headless_json(&["browser", "list-tabs", "--session", &sid], 10);
assert_success(&out, "list-tabs after close");
let v = parse_json(&out);
assert_eq!(v["data"]["total_tabs"], serde_json::json!(2));
let tabs = v["data"]["tabs"].as_array().unwrap();
let ids: Vec<&str> = tabs.iter().filter_map(|t| t["tab_id"].as_str()).collect();
assert!(ids.contains(&t1.as_str()), "t1 should remain");
assert!(ids.contains(&t3.as_str()), "t3 should remain");
assert!(!ids.contains(&t2.as_str()), "t2 should be closed");
}
// ===========================================================================
// Group 4: Error Cases
// ===========================================================================
#[test]
fn tab_list_tabs_nonexistent_session_json() {
if skip() {
return;
}
let out = headless_json(&["browser", "list-tabs", "--session", "nonexistent"], 10);
assert_failure(&out, "list-tabs nonexistent session");
let v = parse_json(&out);
assert_eq!(v["ok"], false);
assert_eq!(v["command"], "browser list-tabs");
assert_error_envelope(&v, "SESSION_NOT_FOUND");
assert!(v["context"].is_null());
}
#[test]
fn tab_list_tabs_nonexistent_session_text() {
if skip() {
return;
}
let out = headless(&["browser", "list-tabs", "--session", "nonexistent"], 10);
assert_failure(&out, "list-tabs nonexistent session text");
let text = stdout_str(&out);
assert!(text.contains("error SESSION_NOT_FOUND:"));
}
#[test]
fn tab_new_tab_nonexistent_session_json() {
if skip() {
return;
}
let url_a = url_a();
let out = headless_json(
&["browser", "new-tab", &url_a, "--session", "nonexistent"],
10,
);
assert_failure(&out, "new-tab nonexistent session");
let v = parse_json(&out);
assert_eq!(v["ok"], false);
assert_eq!(v["command"], "browser new-tab");
assert_error_envelope(&v, "SESSION_NOT_FOUND");
assert!(v["context"].is_null());
}
#[test]
fn tab_new_tab_nonexistent_session_text() {
if skip() {
return;
}
let url_a = url_a();
let out = headless(
&["browser", "new-tab", &url_a, "--session", "nonexistent"],
10,
);
assert_failure(&out, "new-tab nonexistent session text");
let text = stdout_str(&out);
assert!(text.contains("error SESSION_NOT_FOUND:"));
}
#[test]
fn tab_close_tab_nonexistent_session_json() {
if skip() {
return;
}
let out = headless_json(
&[
"browser",
"close-tab",
"--session",
"nonexistent",
"--tab",
"fake",
],
10,
);
assert_failure(&out, "close-tab nonexistent session");
let v = parse_json(&out);
assert_eq!(v["ok"], false);
assert_eq!(v["command"], "browser close-tab");
assert_error_envelope(&v, "SESSION_NOT_FOUND");
assert!(v["context"].is_null());
}
#[test]
fn tab_close_tab_nonexistent_session_text() {
if skip() {
return;
}
let out = headless(
&[
"browser",
"close-tab",
"--session",
"nonexistent",
"--tab",
"fake",
],
10,
);
assert_failure(&out, "close-tab nonexistent session text");
let text = stdout_str(&out);
assert!(text.contains("error SESSION_NOT_FOUND:"));
}
#[test]
fn tab_close_tab_nonexistent_tab_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless_json(
&[
"browser",
"close-tab",
"--session",
&sid,
"--tab",
"nonexistent-tab-id",
],
10,
);
assert_failure(&out, "close-tab nonexistent tab");
let v = parse_json(&out);
assert_eq!(v["ok"], false);
assert_eq!(v["command"], "browser close-tab");
assert_error_envelope(&v, "TAB_NOT_FOUND");
assert!(
v["context"].is_object(),
"context should be present when session found"
);
assert_eq!(v["context"]["session_id"], sid);
}
#[test]
fn tab_close_tab_nonexistent_tab_text() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless(
&[
"browser",
"close-tab",
"--session",
&sid,
"--tab",
"nonexistent-tab-id",
],
10,
);
assert_failure(&out, "close-tab nonexistent tab text");
let text = stdout_str(&out);
assert!(text.contains("error TAB_NOT_FOUND:"));
}
#[test]
fn tab_close_tab_double_close_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let t2 = new_tab_json(&sid, &url_b());
// First close: success
let out = headless_json(
&["browser", "close-tab", "--session", &sid, "--tab", &t2],
30,
);
assert_success(&out, "first close");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["data"]["closed_tab_id"], t2);
// Second close: TAB_NOT_FOUND
let out = headless_json(
&["browser", "close-tab", "--session", &sid, "--tab", &t2],
30,
);
assert_failure(&out, "second close should fail");
let v = parse_json(&out);
assert_eq!(v["ok"], false);
assert_error_envelope(&v, "TAB_NOT_FOUND");
}
// ===========================================================================
// Group 5: Concurrent — Same Session
// ===========================================================================
#[test]
fn tab_concurrent_multi_tab_same_session() {
if skip() {
return;
}
let (sid, t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let t2 = new_tab_json(&sid, &url_b());
let t3 = new_tab_json(&sid, &url_c());
let sid1 = sid.clone();
let t1c = t1.clone();
let sid2 = sid.clone();
let t2c = t2.clone();
let sid3 = sid.clone();
let t3c = t3.clone();
let h1 = std::thread::spawn(move || {
headless_json(
&["browser", "eval", "1+1", "--session", &sid1, "--tab", &t1c],
30,
)
});
let h2 = std::thread::spawn(move || {
headless_json(
&["browser", "eval", "1+1", "--session", &sid2, "--tab", &t2c],
30,
)
});
let h3 = std::thread::spawn(move || {
headless_json(
&["browser", "eval", "1+1", "--session", &sid3, "--tab", &t3c],
30,
)
});
let out1 = h1.join().expect("thread t1");
let out2 = h2.join().expect("thread t2");
let out3 = h3.join().expect("thread t3");
assert_success(&out1, "eval t1");
assert_success(&out2, "eval t2");
assert_success(&out3, "eval t3");
for out in [&out1, &out2, &out3] {
let v = parse_json(out);
assert_eq!(v["context"]["session_id"], sid);
}
let out = headless_json(&["browser", "list-tabs", "--session", &sid], 10);
assert_success(&out, "list-tabs 3 tabs");
let v = parse_json(&out);
assert_eq!(v["data"]["total_tabs"], serde_json::json!(3));
}
// ===========================================================================
// Group 6: Concurrent — Cross-Session
// ===========================================================================
#[test]
fn tab_concurrent_multi_tab_cross_session() {
if skip() {
return;
}
let (sid_a, prof_a) = unique_session("cross-a");
let (sid_b, prof_b) = unique_session("cross-b");
let _ta = start_named_session(&sid_a, &prof_a, &url_a());
let _guard_a = SessionGuard::new(&sid_a);
let _tb = start_named_session(&sid_b, &prof_b, &url_c());
let _guard_b = SessionGuard::new(&sid_b);
let _t2a = new_tab_json(&sid_a, &url_b());
let _t2b = new_tab_json(&sid_b, &url_a());
let sa = sid_a.clone();
let sb = sid_b.clone();
let ha =
std::thread::spawn(move || headless_json(&["browser", "list-tabs", "--session", &sa], 10));
let hb =
std::thread::spawn(move || headless_json(&["browser", "list-tabs", "--session", &sb], 10));
let out_a = ha.join().unwrap();
let out_b = hb.join().unwrap();
assert_success(&out_a, "list-tabs session-a");
assert_success(&out_b, "list-tabs session-b");
let va = parse_json(&out_a);
let vb = parse_json(&out_b);
assert_eq!(va["data"]["total_tabs"], serde_json::json!(2));
assert_eq!(vb["data"]["total_tabs"], serde_json::json!(2));
}
#[test]
fn tab_concurrent_close_tabs_cross_session() {
if skip() {
return;
}
let (sid_x, prof_x) = unique_session("close-x");
let (sid_y, prof_y) = unique_session("close-y");
let _tx = start_named_session(&sid_x, &prof_x, &url_a());
let _guard_x = SessionGuard::new(&sid_x);
let _ty = start_named_session(&sid_y, &prof_y, &url_c());
let _guard_y = SessionGuard::new(&sid_y);
let t2x = new_tab_json(&sid_x, &url_b());
let t2y = new_tab_json(&sid_y, &url_a());
let sx = sid_x.clone();
let sy = sid_y.clone();
let tx_clone = t2x.clone();
let ty_clone = t2y.clone();
let hx = std::thread::spawn(move || {
headless_json(
&["browser", "close-tab", "--session", &sx, "--tab", &tx_clone],
30,
)
});
let hy = std::thread::spawn(move || {
headless_json(
&["browser", "close-tab", "--session", &sy, "--tab", &ty_clone],
30,
)
});
let out_x = hx.join().unwrap();
let out_y = hy.join().unwrap();
assert_success(&out_x, "close-tab session-x");
assert_success(&out_y, "close-tab session-y");
let vx = parse_json(&out_x);
let vy = parse_json(&out_y);
assert_eq!(vx["data"]["closed_tab_id"], t2x);
assert_eq!(vy["data"]["closed_tab_id"], t2y);
let out = headless_json(&["browser", "list-tabs", "--session", &sid_x], 10);
let v = parse_json(&out);
assert_eq!(v["data"]["total_tabs"], serde_json::json!(1));
let out = headless_json(&["browser", "list-tabs", "--session", &sid_y], 10);
let v = parse_json(&out);
assert_eq!(v["data"]["total_tabs"], serde_json::json!(1));
}
// ===========================================================================
// Group 7: list-tabs — CDP-based real-time sync
// ===========================================================================
use crate::harness::SoloEnv;
/// list-tabs reflects real CDP state, not stale registry.
/// After restart, old native_tab_ids are gone — list-tabs must still return tabs.
#[test]
fn tab_list_tabs_syncs_after_restart() {
if skip() {
return;
}
let env = SoloEnv::new();
// Start session
let out = env.headless_json(
&[
"browser",
"start",
"--mode",
"local",
"--headless",
"--set-session-id",
"sync-test",
"--profile",
"sync-prof",
"--open-url",
&url_a(),
],
30,
);
assert_success(&out, "start sync-test");
// list-tabs before restart — should have 1 tab
let out = env.headless_json(&["browser", "list-tabs", "--session", "sync-test"], 10);
assert_success(&out, "list-tabs before restart");
let v = parse_json(&out);
assert!(
v["data"]["total_tabs"].as_u64().unwrap_or(0) >= 1,
"should have at least 1 tab before restart"
);
let old_native_id = v["data"]["tabs"][0]["native_tab_id"]
.as_str()
.unwrap_or("")
.to_string();
// Restart — Chrome process replaced, native_tab_ids change
let out = env.headless_json(&["browser", "restart", "--session", "sync-test"], 30);
assert_success(&out, "restart sync-test");
// list-tabs after restart — should still show tabs (synced from CDP)
let out = env.headless_json(&["browser", "list-tabs", "--session", "sync-test"], 10);
assert_success(&out, "list-tabs after restart");
let v = parse_json(&out);
let total = v["data"]["total_tabs"].as_u64().unwrap_or(0);
assert!(
total >= 1,
"should have at least 1 tab after restart, got {total}"
);
// Native tab ID should be different from before restart
let new_native_id = v["data"]["tabs"][0]["native_tab_id"]
.as_str()
.unwrap_or("")
.to_string();
assert_ne!(
old_native_id, new_native_id,
"native_tab_id should change after restart"
);
// Short tab ID should still be valid tN format
assert_tab_id(&v["data"]["tabs"][0]["tab_id"]);
}
/// list-tabs preserves existing short IDs for known tabs after sync.
#[test]
fn tab_list_tabs_preserves_known_ids_after_sync() {
if skip() {
return;
}
let (sid, t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let t2 = new_tab_json(&sid, &url_b());
// list-tabs should return t1 and t2 with stable IDs
let out = headless_json(&["browser", "list-tabs", "--session", &sid], 10);
assert_success(&out, "list-tabs with 2 tabs");
let v = parse_json(&out);
assert_eq!(v["data"]["total_tabs"], serde_json::json!(2));
let tabs = v["data"]["tabs"].as_array().unwrap();
let ids: Vec<&str> = tabs.iter().filter_map(|t| t["tab_id"].as_str()).collect();
assert!(ids.contains(&t1.as_str()), "should preserve t1");
assert!(ids.contains(&t2.as_str()), "should preserve t2");
// Call list-tabs again — IDs should be identical (stable)
let out2 = headless_json(&["browser", "list-tabs", "--session", &sid], 10);
assert_success(&out2, "list-tabs second call");
let v2 = parse_json(&out2);
let tabs2 = v2["data"]["tabs"].as_array().unwrap();
let ids2: Vec<&str> = tabs2.iter().filter_map(|t| t["tab_id"].as_str()).collect();
assert_eq!(ids, ids2, "tab IDs should be stable across list-tabs calls");
}
// ===========================================================================
// --tab alias for --set-tab-id
// ===========================================================================
// ===========================================================================
// Group 8: batch-new-tab
// ===========================================================================
#[test]
fn tab_batch_open_json() {
if skip() {
return;
}
let (sid, t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let ub = url_b();
let uc = url_c();
let out = headless_json(
&[
"browser",
"batch-new-tab",
"--urls",
&ub,
&uc,
"--session",
&sid,
],
30,
);
assert_success(&out, "batch-new-tab json");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["command"], "browser batch-new-tab");
assert!(v["error"].is_null());
assert_context_object(&v);
assert_eq!(v["context"]["session_id"], sid);
assert_eq!(v["data"]["action"], "batch-new-tab");
assert_eq!(v["data"]["opened"], 2);
let tabs = v["data"]["tabs"].as_array().expect("tabs array");
assert_eq!(tabs.len(), 2);
for tab in tabs {
assert_tab_id(&tab["tab_id"]);
assert_native_tab_id(&tab["native_tab_id"]);
assert!(tab["url"].is_string());
}
// Tab IDs should be sequential after t1
let t2 = tabs[0]["tab_id"].as_str().unwrap();
let t3 = tabs[1]["tab_id"].as_str().unwrap();
assert_ne!(t2, t1);
assert_ne!(t2, t3);
assert_meta(&v);
}
#[test]
fn tab_batch_open_text() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let ub = url_b();
let uc = url_c();
let out = headless(
&[
"browser",
"batch-new-tab",
"--urls",
&ub,
&uc,
"--session",
&sid,
],
30,
);
assert_success(&out, "batch-new-tab text");
let text = stdout_str(&out);
assert!(text.contains(&format!("[{sid}]")));
assert!(text.contains("ok browser batch-new-tab"));
}
#[test]
fn tab_batch_open_custom_tabs_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let ub = url_b();
let uc = url_c();
let out = headless_json(
&[
"browser",
"batch-new-tab",
"--urls",
&ub,
&uc,
"--tabs",
"inbox",
"settings",
"--session",
&sid,
],
30,
);
assert_success(&out, "batch-new-tab custom tabs");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["data"]["opened"], 2);
let tabs = v["data"]["tabs"].as_array().unwrap();
assert_eq!(tabs[0]["tab_id"], "inbox");
assert_eq!(tabs[1]["tab_id"], "settings");
assert_native_tab_id(&tabs[0]["native_tab_id"]);
assert_native_tab_id(&tabs[1]["native_tab_id"]);
}
#[test]
fn tab_batch_open_alias_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let ub = url_b();
let out = headless_json(
&["browser", "batch-open", "--urls", &ub, "--session", &sid],
30,
);
assert_success(&out, "batch-open alias");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["command"], "browser batch-new-tab");
assert_eq!(v["data"]["action"], "batch-new-tab");
assert_eq!(v["data"]["opened"], 1);
}
#[test]
fn tab_batch_open_tabs_list_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let ub = url_b();
let uc = url_c();
let out = headless_json(
&[
"browser",
"batch-new-tab",
"--urls",
&ub,
&uc,
"--session",
&sid,
],
30,
);
assert_success(&out, "batch-new-tab before list");
// list-tabs should show 3 tabs (original t1 + 2 batch-opened)
let out = headless_json(&["browser", "list-tabs", "--session", &sid], 10);
assert_success(&out, "list-tabs after batch-open");
let v = parse_json(&out);
assert_eq!(v["data"]["total_tabs"], serde_json::json!(3));
}
#[test]
fn tab_batch_open_size_mismatch_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let ub = url_b();
let uc = url_c();
let out = headless_json(
&[
"browser",
"batch-new-tab",
"--urls",
&ub,
&uc,
&url_a(),
"--tabs",
"a",
"b",
"--session",
&sid,
],
10,
);
assert_failure(&out, "size mismatch");
let v = parse_json(&out);
assert_eq!(v["ok"], false);
assert_error_envelope(&v, "INVALID_ARGUMENT");
}
#[test]
fn tab_batch_open_duplicate_tab_ids_json() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let ub = url_b();
let uc = url_c();
let out = headless_json(
&[
"browser",
"batch-new-tab",
"--urls",
&ub,
&uc,
"--tabs",
"dup",
"dup",
"--session",
&sid,
],
10,
);
assert_failure(&out, "duplicate tab IDs");
let v = parse_json(&out);
assert_eq!(v["ok"], false);
assert_error_envelope(&v, "INVALID_ARGUMENT");
}
#[test]
fn tab_batch_open_nonexistent_session_json() {
if skip() {
return;
}
let ub = url_b();
let out = headless_json(
&[
"browser",
"batch-new-tab",
"--urls",
&ub,
"--session",
"nonexistent",
],
10,
);
assert_failure(&out, "nonexistent session");
let v = parse_json(&out);
assert_eq!(v["ok"], false);
assert_error_envelope(&v, "SESSION_NOT_FOUND");
assert!(v["context"].is_null());
}
#[test]
fn tab_batch_open_tab_conflict_json() {
if skip() {
return;
}
let (sid, t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let ub = url_b();
let out = headless_json(
&[
"browser",
"batch-new-tab",
"--urls",
&ub,
"--tabs",
&t1,
"--session",
&sid,
],
10,
);
assert_failure(&out, "tab ID conflict");
let v = parse_json(&out);
assert_eq!(v["ok"], false);
// Conflict hits BATCH_OPEN_ERROR wrapping TAB_ID_CONFLICT
assert_eq!(v["error"]["code"], "BATCH_OPEN_ERROR");
}
/// `browser new-tab --tab inbox` creates a tab with the given custom ID.
#[test]
fn tab_new_tab_with_tab_alias_creates() {
if skip() {
return;
}
let (sid, _t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
let out = headless_json(
&[
"browser",
"new-tab",
&url_b(),
"--session",
&sid,
"--tab",
"inbox",
],
30,
);
assert_success(&out, "new-tab with --tab alias");
let v = parse_json(&out);
assert_eq!(
v["data"]["tab"]["tab_id"], "inbox",
"tab_id must match the provided --tab value"
);
assert_eq!(v["data"]["created"], true);
}
/// `browser new-tab --tab tid` with a conflicting ID returns TAB_ID_CONFLICT.
#[test]
fn tab_new_tab_with_tab_alias_conflict() {
if skip() {
return;
}
let (sid, t1) = start_session(&url_a());
let _guard = SessionGuard::new(&sid);
// t1 already exists from start_session
let out = headless_json(
&[
"browser",
"new-tab",
&url_b(),
"--session",
&sid,
"--tab",
&t1,
],
30,
);
assert_failure(&out, "duplicate --tab alias must fail");
let v = parse_json(&out);
assert_eq!(
v["error"]["code"], "TAB_ID_CONFLICT",
"conflict should return TAB_ID_CONFLICT"
);
}