Consistent Tab IDs & Global Tag Targeting (#892)

Introduces stable per-tab IDs and a global `--tab <id>` flag for scoping individual commands to a specific tab.

Breaking change: response payloads for `tab_list`, `tab_new`, `tab_switch`, `tab_close`, and `window_new` now use `tabId` instead of `index`. `tab_close` returns `{tabId, closed: true}` instead of `{closed, activeIndex}`. `agent-browser tab <unknown>` now errors instead of silently listing tabs.

Follow-up PR to land immediately after this fixes a compile error on the provider direct-page path, clears per-tab daemon state around scoped switches, and implements active-tab restoration so `--tab N` is non-intrusive as intended.
This commit is contained in:
Daniel Hails
2026-04-16 12:02:55 -05:00
committed by GitHub
parent c691b269cb
commit 67dc631977
7 changed files with 618 additions and 45 deletions
+332 -5
View File
@@ -905,12 +905,13 @@ async fn e2e_tabs() {
.await;
assert_success(&resp);
// Tab list should show 1 tab
// Tab list should show 1 tab with tabId 1
let resp = execute_command(&json!({ "id": "3", "action": "tab_list" }), &mut state).await;
assert_success(&resp);
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
assert_eq!(tabs.len(), 1);
assert_eq!(tabs[0]["active"], true);
assert_eq!(tabs[0]["tabId"], 1, "First tab should have tabId 1");
// Open new tab
let resp = execute_command(
@@ -919,18 +920,21 @@ async fn e2e_tabs() {
)
.await;
assert_success(&resp);
assert_eq!(get_data(&resp)["index"], 1);
assert_eq!(get_data(&resp)["tabId"], 2, "New tab should have tabId 2");
assert_eq!(get_data(&resp)["total"], 2);
// Tab list should show 2 tabs
// Tab list should show 2 tabs with distinct, incrementing tabIds
let resp = execute_command(&json!({ "id": "5", "action": "tab_list" }), &mut state).await;
assert_success(&resp);
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
assert_eq!(tabs.len(), 2);
assert_eq!(tabs[1]["active"], true);
assert_eq!(tabs[0]["tabId"], 1, "First tab should keep tabId 1");
assert_eq!(tabs[1]["tabId"], 2, "Second tab should have tabId 2");
// Switch to first tab
let resp = execute_command(
&json!({ "id": "6", "action": "tab_switch", "index": 0 }),
&json!({ "id": "6", "action": "tab_switch", "tabId": 1 }),
&mut state,
)
.await;
@@ -946,7 +950,7 @@ async fn e2e_tabs() {
// Close second tab
let resp = execute_command(
&json!({ "id": "8", "action": "tab_close", "index": 1 }),
&json!({ "id": "8", "action": "tab_close", "tabId": 2 }),
&mut state,
)
.await;
@@ -962,6 +966,329 @@ async fn e2e_tabs() {
assert_success(&resp);
}
#[tokio::test]
#[ignore]
async fn e2e_tab_ids_not_reused() {
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// First tab gets tabId 1
let resp = execute_command(&json!({ "id": "2", "action": "tab_list" }), &mut state).await;
assert_success(&resp);
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
assert_eq!(tabs[0]["tabId"], 1);
// Open tab 2 and tab 3
let resp = execute_command(
&json!({ "id": "3", "action": "tab_new", "url": "data:text/html,<h1>Tab 2</h1>" }),
&mut state,
)
.await;
assert_success(&resp);
assert_eq!(get_data(&resp)["tabId"], 2);
let resp = execute_command(
&json!({ "id": "4", "action": "tab_new", "url": "data:text/html,<h1>Tab 3</h1>" }),
&mut state,
)
.await;
assert_success(&resp);
assert_eq!(get_data(&resp)["tabId"], 3);
// Close tab 2
let resp = execute_command(
&json!({ "id": "5", "action": "tab_close", "tabId": 2 }),
&mut state,
)
.await;
assert_success(&resp);
// Open a new tab — should get tabId 4, NOT 2
let resp = execute_command(
&json!({ "id": "6", "action": "tab_new", "url": "data:text/html,<h1>Tab 4</h1>" }),
&mut state,
)
.await;
assert_success(&resp);
assert_eq!(
get_data(&resp)["tabId"],
4,
"Tab IDs must not be reused after closing"
);
// Verify final state: tabs 1, 3, 4
let resp = execute_command(&json!({ "id": "7", "action": "tab_list" }), &mut state).await;
assert_success(&resp);
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
assert_eq!(tabs.len(), 3);
let ids: Vec<i64> = tabs.iter().map(|t| t["tabId"].as_i64().unwrap()).collect();
assert_eq!(ids, vec![1, 3, 4]);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
#[tokio::test]
#[ignore]
async fn e2e_tab_global_targeting() {
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// Navigate tab 1
let resp = execute_command(
&json!({ "id": "2", "action": "navigate", "url": "data:text/html,<h1>Page A</h1>" }),
&mut state,
)
.await;
assert_success(&resp);
// Open tab 2 (becomes active)
let resp = execute_command(
&json!({ "id": "3", "action": "tab_new", "url": "data:text/html,<h1>Page B</h1>" }),
&mut state,
)
.await;
assert_success(&resp);
assert_eq!(get_data(&resp)["tabId"], 2);
// Use tabId to evaluate on tab 1 while tab 2 is active
// (simulates --tab 1 evaluate ...)
let resp = execute_command(
&json!({ "id": "4", "action": "evaluate", "tabId": 1, "script": "document.querySelector('h1').textContent" }),
&mut state,
)
.await;
assert_success(&resp);
assert_eq!(
get_data(&resp)["result"],
"Page A",
"tabId should target tab 1 even though tab 2 was active"
);
// Verify tab 2 content is still accessible
let resp = execute_command(
&json!({ "id": "5", "action": "evaluate", "tabId": 2, "script": "document.querySelector('h1').textContent" }),
&mut state,
)
.await;
assert_success(&resp);
assert_eq!(get_data(&resp)["result"], "Page B");
// Without tabId, should use the current active tab (now tab 1 from the switch)
let resp = execute_command(
&json!({ "id": "6", "action": "evaluate", "script": "document.querySelector('h1').textContent" }),
&mut state,
)
.await;
assert_success(&resp);
// After targeting tab 1 then tab 2, active tab is now tab 2
assert_eq!(get_data(&resp)["result"], "Page B");
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
#[tokio::test]
#[ignore]
async fn e2e_tab_global_targeting_snapshot() {
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// Navigate tab 1
let resp = execute_command(
&json!({ "id": "2", "action": "navigate", "url": "data:text/html,<h1>Page A</h1>" }),
&mut state,
)
.await;
assert_success(&resp);
// Open tab 2 (becomes active)
let resp = execute_command(
&json!({ "id": "3", "action": "tab_new", "url": "data:text/html,<h1>Page B</h1>" }),
&mut state,
)
.await;
assert_success(&resp);
assert_eq!(get_data(&resp)["tabId"], 2);
// Snapshot tab 1 via tabId while tab 2 is active
let resp = execute_command(
&json!({ "id": "4", "action": "snapshot", "tabId": 1 }),
&mut state,
)
.await;
assert_success(&resp);
let snapshot = get_data(&resp)["snapshot"].as_str().unwrap();
assert!(
snapshot.contains("Page A"),
"Snapshot with tabId=1 should contain 'Page A', got: {}",
snapshot
);
assert!(
!snapshot.contains("Page B"),
"Snapshot with tabId=1 should NOT contain 'Page B', got: {}",
snapshot
);
// Snapshot tab 2 via tabId
let resp = execute_command(
&json!({ "id": "5", "action": "snapshot", "tabId": 2 }),
&mut state,
)
.await;
assert_success(&resp);
let snapshot = get_data(&resp)["snapshot"].as_str().unwrap();
assert!(
snapshot.contains("Page B"),
"Snapshot with tabId=2 should contain 'Page B', got: {}",
snapshot
);
assert!(
!snapshot.contains("Page A"),
"Snapshot with tabId=2 should NOT contain 'Page A', got: {}",
snapshot
);
// Snapshot without tabId should use the last-switched tab (tab 2)
let resp = execute_command(&json!({ "id": "6", "action": "snapshot" }), &mut state).await;
assert_success(&resp);
let snapshot = get_data(&resp)["snapshot"].as_str().unwrap();
assert!(
snapshot.contains("Page B"),
"Snapshot without tabId should use active tab (Page B), got: {}",
snapshot
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
#[tokio::test]
#[ignore]
async fn e2e_tab_global_targeting_snapshot_non_contiguous() {
// Reproduces the bug where --tab 3 snapshot shows tab 1's content
// when tab IDs are non-contiguous (e.g. tabs [1] and [3] after
// closing tab [2]).
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// Navigate tab 1 to Page A
let resp = execute_command(
&json!({ "id": "2", "action": "navigate", "url": "data:text/html,<h1>Page A</h1>" }),
&mut state,
)
.await;
assert_success(&resp);
// Open tab 2
let resp = execute_command(
&json!({ "id": "3", "action": "tab_new", "url": "data:text/html,<h1>Page B</h1>" }),
&mut state,
)
.await;
assert_success(&resp);
assert_eq!(get_data(&resp)["tabId"], 2);
// Open tab 3
let resp = execute_command(
&json!({ "id": "4", "action": "tab_new", "url": "data:text/html,<h1>Page C</h1>" }),
&mut state,
)
.await;
assert_success(&resp);
assert_eq!(get_data(&resp)["tabId"], 3);
// Close tab 2 to create non-contiguous IDs: [1, 3]
let resp = execute_command(
&json!({ "id": "5", "action": "tab_close", "tabId": 2 }),
&mut state,
)
.await;
assert_success(&resp);
// Verify tab list shows [1] and [3]
let resp = execute_command(&json!({ "id": "6", "action": "tab_list" }), &mut state).await;
assert_success(&resp);
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
assert_eq!(tabs.len(), 2);
assert_eq!(tabs[0]["tabId"], 1);
assert_eq!(tabs[1]["tabId"], 3);
// Switch active tab back to tab 1
let resp = execute_command(
&json!({ "id": "7", "action": "tab_switch", "tabId": 1 }),
&mut state,
)
.await;
assert_success(&resp);
// Snapshot tab 3 via tabId while tab 1 is active
// (simulates: --tab 3 snapshot)
let resp = execute_command(
&json!({ "id": "8", "action": "snapshot", "tabId": 3 }),
&mut state,
)
.await;
assert_success(&resp);
let snapshot = get_data(&resp)["snapshot"].as_str().unwrap();
assert!(
snapshot.contains("Page C"),
"Snapshot with tabId=3 should contain 'Page C', got: {}",
snapshot
);
assert!(
!snapshot.contains("Page A"),
"Snapshot with tabId=3 should NOT contain 'Page A', got: {}",
snapshot
);
// Snapshot tab 1 via tabId
let resp = execute_command(
&json!({ "id": "9", "action": "snapshot", "tabId": 1 }),
&mut state,
)
.await;
assert_success(&resp);
let snapshot = get_data(&resp)["snapshot"].as_str().unwrap();
assert!(
snapshot.contains("Page A"),
"Snapshot with tabId=1 should contain 'Page A', got: {}",
snapshot
);
assert!(
!snapshot.contains("Page C"),
"Snapshot with tabId=1 should NOT contain 'Page C', got: {}",
snapshot
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
// ---------------------------------------------------------------------------
// Element queries: isvisible, isenabled, gettext, getattribute
// ---------------------------------------------------------------------------