feat(tabs): t<N> prefix for tab ids; --label for named tabs; drop --tab peek flag (#1250)
* fix(tabs): preserve refs across --tab peek and cover outer-tab-closed path Follow-up to #1249 so `--tab <id>` is actually useful for agents: - Save and restore the outer tab's `ref_map`, `iframe_sessions`, and `active_frame_id` across a scoped command instead of clearing them. `snapshot` → `--tab N <cmd>` → `click @e1` now keeps the outer tab's refs intact. Scoped commands still see a clean slate so outer refs can't resolve against the scoped tab's DOM. - Close the coverage gap the Vercel review bot flagged on #1249: the previous `e2e_tab_scoped_command_handles_outer_tab_closed` test used `tab_close`, which is in the scoped-dispatch exclusion list, so it never exercised the restore-skip branch it claimed to test. Renamed to `e2e_tab_close_with_tab_id_closes_active_tab` with an honest docstring, and added `e2e_tab_scoped_command_outer_tab_closed_mid_dispatch` that actually hits the branch via `window.opener.close()` on a script-opened intermediate tab. - Add `e2e_tab_scoped_command_isolates_refs_from_outer_tab` pinning that outer refs don't bleed into the scoped tab's DOM resolution. - Rewrite `e2e_tab_scoped_command_clears_state_on_switch` as `e2e_tab_scoped_command_preserves_outer_tab_state`, verifying the restored @e1 still clicks end-to-end. - Update the 52 `--help` entries for `--tab <id>` to describe peek / restore semantics instead of a vague "Target specific tab ID". - Update README, docs site, config schema, and the agent-facing skills reference with working examples (refs survive the peek) and a "when to use \`--tab <id>\` vs \`tab <id>\`" guide so agents pick the right flag for their workflow. * fix(tabs): use t<N> prefix for tab ids, add --label for named tabs Follow-on to the tab work in #1249 and the prior commit, redesigning the tab handle surface before release since nothing ships these features yet. ## Why Incrementing integer tab ids (`1`, `2`, `3`) look indistinguishable from positional indices in command output, LLM-generated scripts, and docs. In the common single-agent case where position and id coincide, readers have no visual cue for which mental model they're using. Positional indices silently shift when unrelated tabs open/close, so misreading a handle as an index is a correctness hazard. ## Changes **Tab ids are now `t1`, `t2`, `t3` (strings).** Bare integer `tabId` values are rejected with a teaching message rather than silently accepted. The `t` prefix matches the `@e1` element-ref convention and makes ids unmistakably non-positional at a glance. **Labels.** Tabs can be created with a user-assigned label (e.g. `docs`, `app`) via `tab new --label <name> [url]`. Labels are interchangeable with `t<N>` ids everywhere a tab ref is accepted. They're never auto-generated, never rewritten on navigation, and must be unique within a session. **Dashboard fix.** `packages/dashboard/src/types.ts` declared `TabInfo.index: number` but the daemon has been sending `tabId` (not `index`) since #892, making `tab.index` `undefined` and breaking the dashboard's close/switch buttons silently. Updated the TS types and usages to consume `tabId` (string) and optional `label`, restoring the dashboard's tab interactions. ## Surface - `cli/src/native/browser.rs`: `TabRef::parse` / `format_tab_id` / `is_valid_label` / `PageInfo.label` / `BrowserManager::resolve_tab_ref` / `BrowserManager::has_label`. `tab_new` gains an optional label argument with duplicate rejection. All JSON responses use the string form and include the label. - `cli/src/native/actions.rs`: scoped-command pre-dispatch and `handle_tab_{switch,close,new}` parse string refs and resolve to stable ids. - `cli/src/{flags,commands,main,output}.rs`: `--tab` / config `tab` are `String`; `tab` subcommand accepts `t<N>` or a label and supports `tab new --label <name> [url]`. All 52 `--help` entries updated. - `agent-browser.schema.json`: `tab` property type is now `string` with a pattern matching `t<N>` or label form. - `packages/dashboard`: `TabInfo.tabId: string` / `label?: string | null`; `closeTabAtom`/`switchTabAtom` take `tabRef: string`; component props updated. - Docs: README, docs site (`commands/` and `configuration/`), and the agent-facing skills reference rewritten with the new examples. ## Tests - Added `TabRef::parse` / `format_tab_id` / `is_valid_label` unit tests pinning the bare-integer rejection, the teaching error, label rules, and round-tripping. - Added `test_tab_switch_by_id` / `_by_label` / `test_tab_new_with_label` / `_with_label_and_url` / `_with_url_then_label` in `commands.rs`; rewrote `test_tab_unknown_subcommand_errors` since labels make `tab select` a legitimate ref. - Added `e2e_tab_new_with_label_can_be_switched_and_peeked`, `e2e_tab_new_with_duplicate_label_errors`, `e2e_tab_scoped_command_rejects_bare_integer`. - Migrated every existing tab e2e test (and one unit test) from integer `tabId` to the string form. `cargo fmt`, `cargo clippy -- -D warnings`, all 30 non-ignored tab unit tests, all 13 tab e2e tests, and `tsc --noEmit` on the dashboard all pass. * refactor(tabs): drop --tab scoped peek flag; keep t<N> ids and labels After fleshing out `--tab <id|label>` in the previous commits (scoped pre/post-dispatch save/restore, ref preservation, outer-tab-closed edge case, full e2e coverage), the machinery-to-value ratio makes the feature hard to justify. Nixing it now while nothing has shipped. ## Why - Every new daemon feature touching per-tab state has to reason about scoped-dispatch interleaving. `ScopedRestore`, pre/post-dispatch hooks, and the exclusion list add ongoing maintenance tax. - Three separate PRs (#892, #1249, and this one pre-nix) were needed to reach "works correctly." That's a smell. - `tab <id|label>` switch + labels already cover the legible multi-tab workflow case. - `--tab` vs `tab <id>` have opposite lifecycle semantics but look identical, teaching every agent two things where one would do. - "Non-disruptive peek" isn't actually race-free: the daemon does swap active tab during execution, so a concurrent client between pre- and post-dispatch sees the scoped tab as active. - Ref-based interaction with scoped tabs never worked ergonomically — refs are per-tab, so `--tab N click @e1` requires `@e1` to already be on tab N, which means a prior switch, which negates the peek. - Adding a feature back is easy; removing shipped API is hard. If per-tab caching (`HashMap<tab_id, RefMap>`) lands later, `--tab` can be reintroduced essentially for free. That's the right time. ## Removed - `--tab <id|label>` global flag (`cli/src/flags.rs`, `cli/src/main.rs`, all 52 `--help` entries in `cli/src/output.rs`). - `tab` property in `agent-browser.schema.json` and the config-options row in `docs/src/app/configuration/page.mdx`. - `ScopedRestore` struct, pre/post-dispatch save/restore in `execute_command` (`cli/src/native/actions.rs`). - `impl Default for RefMap` in `cli/src/native/element.rs` (only added for `mem::take` in the scoped machinery). - `e2e_tab_global_targeting`, `_snapshot`, `_snapshot_non_contiguous`, `e2e_tab_scoped_command_preserves_outer_tab_state`, `_isolates_refs_from_outer_tab`, `_restores_active_tab`, `_outer_tab_closed_mid_dispatch`. 590 lines. - The "When to use `--tab` vs `tab <id|label>`" sections in README, docs site, and skills reference. ## Kept - Stable tab ids (`t1`, `t2`, `t3`) with bare-integer rejection. - User-assigned labels (`tab new --label docs [url]`), with duplicate rejection and interchangeable use everywhere a tab ref is accepted. - `BrowserManager::{active_tab_id, has_tab_id, resolve_tab_ref, has_label}` accessors (still used by the remaining tab handlers). - `TabRef::parse`, `format_tab_id`, `is_valid_label` and their unit tests. - Dashboard TS fix (`TabInfo.tabId` + `label`). - `e2e_tab_close_with_tab_id_closes_active_tab` (renamed docstring to drop the gone exclusion-list reference). - `e2e_tab_new_with_label_can_be_switched_and_closed` (rewrite of the previous `_and_peeked` test — now exercises only switch and close). - `e2e_tab_switch_rejects_bare_integer` (rewrite targeting the `tab_switch` daemon handler rather than the removed scoped path). net: -900 lines across 12 files. `cargo fmt`, `cargo clippy -D warnings`, all 25 non-ignored tab unit tests, all 6 tab e2e tests, and `tsc --noEmit` on the dashboard all pass.
This commit is contained in:
@@ -290,22 +290,30 @@ agent-browser network har stop [output.har] # Stop and save HAR (temp path if
|
|||||||
### Tabs & Windows
|
### Tabs & Windows
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-browser tab # List tabs (shows stable `tabId` for each)
|
agent-browser tab # List tabs (shows `tabId` and optional label)
|
||||||
agent-browser tab new [url] # New tab (optionally with URL)
|
agent-browser tab new [url] # New tab (optionally with URL)
|
||||||
agent-browser tab <id> # Switch to tab by id
|
agent-browser tab new --label docs [url] # New tab with a user-assigned label
|
||||||
agent-browser tab close [id] # Close tab by id (defaults to active tab)
|
agent-browser tab <t<N>|label> # Switch to a tab by id or label
|
||||||
agent-browser window new # New window
|
agent-browser tab close [t<N>|label] # Close a tab (defaults to active)
|
||||||
|
agent-browser window new # New window
|
||||||
```
|
```
|
||||||
|
|
||||||
Tab IDs are stable and never reused within a session, so agents can keep
|
Tab ids are stable strings of the form `t1`, `t2`, `t3`. They're never reused
|
||||||
referring to the same tab across commands even if other tabs are opened or
|
within a session, so scripts and agents can keep referring to the same tab
|
||||||
closed in between. To run a single command against a specific tab without
|
even after other tabs are opened or closed. Positional integers like `tab 2`
|
||||||
changing the active tab, use the global `--tab <id>` flag:
|
are **not** accepted; the `t` prefix disambiguates handles from indices and
|
||||||
|
mirrors the `@e1` convention used for element refs.
|
||||||
|
|
||||||
|
You can also assign a memorable label (`docs`, `app`, `admin`) and use it
|
||||||
|
interchangeably with the id. Labels are never auto-generated and never
|
||||||
|
rewritten on navigation — they're yours to name and keep:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-browser tab new https://docs.example.com # opens and activates tab 2
|
agent-browser tab new --label docs https://docs.example.com
|
||||||
agent-browser --tab 1 snapshot # peek at tab 1 (tab 2 stays active)
|
agent-browser tab docs # switch to the docs tab
|
||||||
agent-browser click "#submit" # runs on tab 2 as expected
|
agent-browser snapshot # populate refs for docs
|
||||||
|
agent-browser click @e3 # click uses docs's refs
|
||||||
|
agent-browser tab close docs # close by label
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frames
|
### Frames
|
||||||
@@ -621,7 +629,6 @@ This is useful for multimodal AI models that can reason about visual layout, unl
|
|||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
|
| `--session <name>` | Use isolated session (or `AGENT_BROWSER_SESSION` env) |
|
||||||
| `--session-name <name>` | Auto-save/restore session state (or `AGENT_BROWSER_SESSION_NAME` env) |
|
| `--session-name <name>` | Auto-save/restore session state (or `AGENT_BROWSER_SESSION_NAME` env) |
|
||||||
| `--tab <id>` | Target a specific tab by stable `tabId` for this command only; the active tab is restored afterward |
|
|
||||||
| `--profile <name\|path>` | Chrome profile name or persistent directory path (or `AGENT_BROWSER_PROFILE` env) |
|
| `--profile <name\|path>` | Chrome profile name or persistent directory path (or `AGENT_BROWSER_PROFILE` env) |
|
||||||
| `--state <path>` | Load storage state from JSON file (or `AGENT_BROWSER_STATE` env) |
|
| `--state <path>` | Load storage state from JSON file (or `AGENT_BROWSER_STATE` env) |
|
||||||
| `--headers <json>` | Set HTTP headers scoped to the URL's origin |
|
| `--headers <json>` | Set HTTP headers scoped to the URL's origin |
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Auto-save/load state persistence name."
|
"description": "Auto-save/load state persistence name."
|
||||||
},
|
},
|
||||||
"tab": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"description": "Target a specific tab by stable tabId for this command only. The active tab is restored afterward."
|
|
||||||
},
|
|
||||||
"executablePath": {
|
"executablePath": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Path to a custom browser executable."
|
"description": "Path to a custom browser executable."
|
||||||
|
|||||||
+105
-29
@@ -1011,31 +1011,51 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
|
|
||||||
// === Tabs ===
|
// === Tabs ===
|
||||||
"tab" => {
|
"tab" => {
|
||||||
const VALID: &[&str] = &["list", "new", "close", "<id>"];
|
|
||||||
match rest.first().copied() {
|
match rest.first().copied() {
|
||||||
Some("new") => {
|
Some("new") => {
|
||||||
|
// Accepted forms:
|
||||||
|
// tab new [url]
|
||||||
|
// tab new --label <name> [url]
|
||||||
|
// tab new [url] --label <name>
|
||||||
let mut cmd = json!({ "id": id, "action": "tab_new" });
|
let mut cmd = json!({ "id": id, "action": "tab_new" });
|
||||||
if let Some(url) = rest.get(1) {
|
let mut i = 1;
|
||||||
cmd["url"] = json!(url);
|
while i < rest.len() {
|
||||||
|
match rest[i] {
|
||||||
|
"--label" => {
|
||||||
|
let name = rest.get(i + 1).ok_or(ParseError::MissingArguments {
|
||||||
|
context: "tab new --label".to_string(),
|
||||||
|
usage: "tab new --label <name> [url]",
|
||||||
|
})?;
|
||||||
|
cmd["label"] = json!(name);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
other if !other.starts_with("--") && cmd.get("url").is_none() => {
|
||||||
|
cmd["url"] = json!(other);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(ParseError::UnknownSubcommand {
|
||||||
|
subcommand: other.to_string(),
|
||||||
|
valid_options: &["--label", "<url>"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(cmd)
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
Some("list") => Ok(json!({ "id": id, "action": "tab_list" })),
|
Some("list") => Ok(json!({ "id": id, "action": "tab_list" })),
|
||||||
Some("close") => {
|
Some("close") => {
|
||||||
let mut cmd = json!({ "id": id, "action": "tab_close" });
|
let mut cmd = json!({ "id": id, "action": "tab_close" });
|
||||||
if let Some(tab_id) = rest.get(1).and_then(|s| s.parse::<i32>().ok()) {
|
if let Some(tab_ref) = rest.get(1) {
|
||||||
cmd["tabId"] = json!(tab_id);
|
cmd["tabId"] = json!(tab_ref);
|
||||||
}
|
}
|
||||||
Ok(cmd)
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
Some(n) if n.parse::<i32>().is_ok() => {
|
Some(tab_ref) => Ok(json!({
|
||||||
let tab_id = n.parse::<i32>().expect("already checked parse succeeds");
|
"id": id,
|
||||||
Ok(json!({ "id": id, "action": "tab_switch", "tabId": tab_id }))
|
"action": "tab_switch",
|
||||||
}
|
"tabId": tab_ref,
|
||||||
Some(sub) => Err(ParseError::UnknownSubcommand {
|
})),
|
||||||
subcommand: sub.to_string(),
|
|
||||||
valid_options: VALID,
|
|
||||||
}),
|
|
||||||
None => Ok(json!({ "id": id, "action": "tab_list" })),
|
None => Ok(json!({ "id": id, "action": "tab_list" })),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2342,7 +2362,6 @@ mod tests {
|
|||||||
fn default_flags() -> Flags {
|
fn default_flags() -> Flags {
|
||||||
Flags {
|
Flags {
|
||||||
session: "test".to_string(),
|
session: "test".to_string(),
|
||||||
tab: None,
|
|
||||||
json: false,
|
json: false,
|
||||||
headed: false,
|
headed: false,
|
||||||
debug: false,
|
debug: false,
|
||||||
@@ -2852,10 +2871,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tab_switch() {
|
fn test_tab_switch_by_id() {
|
||||||
let cmd = parse_command(&args("tab 2"), &default_flags()).unwrap();
|
let cmd = parse_command(&args("tab t2"), &default_flags()).unwrap();
|
||||||
assert_eq!(cmd["action"], "tab_switch");
|
assert_eq!(cmd["action"], "tab_switch");
|
||||||
assert_eq!(cmd["tabId"], 2);
|
assert_eq!(cmd["tabId"], "t2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tab_switch_by_label() {
|
||||||
|
let cmd = parse_command(&args("tab docs"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "tab_switch");
|
||||||
|
assert_eq!(cmd["tabId"], "docs");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2866,23 +2892,57 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tab_close_with_id() {
|
fn test_tab_close_with_id() {
|
||||||
let cmd = parse_command(&args("tab close 2"), &default_flags()).unwrap();
|
let cmd = parse_command(&args("tab close t2"), &default_flags()).unwrap();
|
||||||
assert_eq!(cmd["action"], "tab_close");
|
assert_eq!(cmd["action"], "tab_close");
|
||||||
assert_eq!(cmd["tabId"], 2);
|
assert_eq!(cmd["tabId"], "t2");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tab_switch_sends_tab_id() {
|
fn test_tab_close_with_label() {
|
||||||
let cmd = parse_command(&args("tab 2"), &default_flags()).unwrap();
|
let cmd = parse_command(&args("tab close docs"), &default_flags()).unwrap();
|
||||||
assert_eq!(cmd["tabId"], 2);
|
assert_eq!(cmd["action"], "tab_close");
|
||||||
|
assert_eq!(cmd["tabId"], "docs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tab_sends_string_tab_id() {
|
||||||
|
let cmd = parse_command(&args("tab t2"), &default_flags()).unwrap();
|
||||||
|
assert!(
|
||||||
|
cmd["tabId"].is_string(),
|
||||||
|
"tabId must be a string, got: {:?}",
|
||||||
|
cmd["tabId"]
|
||||||
|
);
|
||||||
assert!(cmd.get("index").is_none());
|
assert!(cmd.get("index").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tab_close_sends_tab_id() {
|
fn test_tab_new_with_label() {
|
||||||
let cmd = parse_command(&args("tab close 3"), &default_flags()).unwrap();
|
let cmd = parse_command(&args("tab new --label docs"), &default_flags()).unwrap();
|
||||||
assert_eq!(cmd["tabId"], 3);
|
assert_eq!(cmd["action"], "tab_new");
|
||||||
assert!(cmd.get("index").is_none());
|
assert_eq!(cmd["label"], "docs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tab_new_with_label_and_url() {
|
||||||
|
let cmd = parse_command(
|
||||||
|
&args("tab new --label docs https://docs.example.com"),
|
||||||
|
&default_flags(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cmd["action"], "tab_new");
|
||||||
|
assert_eq!(cmd["label"], "docs");
|
||||||
|
assert_eq!(cmd["url"], "https://docs.example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tab_new_with_url_then_label() {
|
||||||
|
let cmd = parse_command(
|
||||||
|
&args("tab new https://docs.example.com --label docs"),
|
||||||
|
&default_flags(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cmd["url"], "https://docs.example.com");
|
||||||
|
assert_eq!(cmd["label"], "docs");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2892,14 +2952,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tab_unknown_subcommand_errors() {
|
fn test_tab_unknown_flag_errors() {
|
||||||
let result = parse_command(&args("tab select 3"), &default_flags());
|
// Unknown flags on `tab new` must error instead of being silently
|
||||||
|
// dropped. This protects against typos like `--labl` or `--new-tab`.
|
||||||
|
let result = parse_command(
|
||||||
|
&args("tab new --unknown-flag https://example.com"),
|
||||||
|
&default_flags(),
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
"tab select should error, not silently fall through to tab_list"
|
"tab new with an unknown flag must error, got: {:?}",
|
||||||
|
result
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tab_non_keyword_treated_as_ref() {
|
||||||
|
// After the shift to `t<N>`/label ids, non-keyword tokens (`select`,
|
||||||
|
// `docs`, etc.) are valid label refs; `tab <something>` routes to
|
||||||
|
// tab_switch and the runtime decides whether the label exists.
|
||||||
|
let cmd = parse_command(&args("tab select"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "tab_switch");
|
||||||
|
assert_eq!(cmd["tabId"], "select");
|
||||||
|
}
|
||||||
|
|
||||||
// === Network ===
|
// === Network ===
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ pub struct Config {
|
|||||||
pub json: Option<bool>,
|
pub json: Option<bool>,
|
||||||
pub debug: Option<bool>,
|
pub debug: Option<bool>,
|
||||||
pub session: Option<String>,
|
pub session: Option<String>,
|
||||||
pub tab: Option<u32>,
|
|
||||||
pub session_name: Option<String>,
|
pub session_name: Option<String>,
|
||||||
pub executable_path: Option<String>,
|
pub executable_path: Option<String>,
|
||||||
pub extensions: Option<Vec<String>>,
|
pub extensions: Option<Vec<String>>,
|
||||||
@@ -99,7 +98,6 @@ impl Config {
|
|||||||
json: other.json.or(self.json),
|
json: other.json.or(self.json),
|
||||||
debug: other.debug.or(self.debug),
|
debug: other.debug.or(self.debug),
|
||||||
session: other.session.or(self.session),
|
session: other.session.or(self.session),
|
||||||
tab: other.tab.or(self.tab),
|
|
||||||
session_name: other.session_name.or(self.session_name),
|
session_name: other.session_name.or(self.session_name),
|
||||||
executable_path: other.executable_path.or(self.executable_path),
|
executable_path: other.executable_path.or(self.executable_path),
|
||||||
extensions: match (self.extensions, other.extensions) {
|
extensions: match (self.extensions, other.extensions) {
|
||||||
@@ -198,7 +196,6 @@ fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
|
|||||||
fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||||
const FLAGS_WITH_VALUE: &[&str] = &[
|
const FLAGS_WITH_VALUE: &[&str] = &[
|
||||||
"--session",
|
"--session",
|
||||||
"--tab",
|
|
||||||
"--headers",
|
"--headers",
|
||||||
"--executable-path",
|
"--executable-path",
|
||||||
"--cdp",
|
"--cdp",
|
||||||
@@ -276,7 +273,6 @@ pub struct Flags {
|
|||||||
pub headed: bool,
|
pub headed: bool,
|
||||||
pub debug: bool,
|
pub debug: bool,
|
||||||
pub session: String,
|
pub session: String,
|
||||||
pub tab: Option<u32>,
|
|
||||||
pub headers: Option<String>,
|
pub headers: Option<String>,
|
||||||
pub executable_path: Option<String>,
|
pub executable_path: Option<String>,
|
||||||
pub cdp: Option<String>,
|
pub cdp: Option<String>,
|
||||||
@@ -359,7 +355,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
.ok()
|
.ok()
|
||||||
.or(config.session)
|
.or(config.session)
|
||||||
.unwrap_or_else(|| "default".to_string()),
|
.unwrap_or_else(|| "default".to_string()),
|
||||||
tab: config.tab,
|
|
||||||
headers: config.headers,
|
headers: config.headers,
|
||||||
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH")
|
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -497,12 +492,6 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"--tab" => {
|
|
||||||
if let Some(s) = args.get(i + 1) {
|
|
||||||
flags.tab = s.parse::<u32>().ok();
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"--idle-timeout" => {
|
"--idle-timeout" => {
|
||||||
if let Some(s) = args.get(i + 1) {
|
if let Some(s) = args.get(i + 1) {
|
||||||
match parse_idle_timeout(s) {
|
match parse_idle_timeout(s) {
|
||||||
@@ -786,7 +775,6 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
// Global flags that always take a value (need to skip the next arg too)
|
// Global flags that always take a value (need to skip the next arg too)
|
||||||
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
|
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
|
||||||
"--session",
|
"--session",
|
||||||
"--tab",
|
|
||||||
"--headers",
|
"--headers",
|
||||||
"--executable-path",
|
"--executable-path",
|
||||||
"--cdp",
|
"--cdp",
|
||||||
@@ -1453,25 +1441,4 @@ mod tests {
|
|||||||
let clean = clean_args(&input);
|
let clean = clean_args(&input);
|
||||||
assert_eq!(clean, vec!["open", "example.com"]);
|
assert_eq!(clean, vec!["open", "example.com"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Tab flag tests ===
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_tab_flag() {
|
|
||||||
let flags = parse_flags(&args("--tab 4 snapshot"));
|
|
||||||
assert_eq!(flags.tab, Some(4));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_clean_args_removes_tab_flag() {
|
|
||||||
let cleaned = clean_args(&args("--tab 4 snapshot"));
|
|
||||||
assert_eq!(cleaned, vec!["snapshot"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_tab_config() {
|
|
||||||
let json = r#"{"tab": 4}"#;
|
|
||||||
let config: Config = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(config.tab, Some(4));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -738,12 +738,6 @@ fn main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(tab_id) = flags.tab {
|
|
||||||
if cmd.get("tabId").is_none() {
|
|
||||||
cmd["tabId"] = json!(tab_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle --password-stdin for auth save
|
// Handle --password-stdin for auth save
|
||||||
if cmd.get("action").and_then(|v| v.as_str()) == Some("auth_save") {
|
if cmd.get("action").and_then(|v| v.as_str()) == Some("auth_save") {
|
||||||
if cmd.get("password").is_some() {
|
if cmd.get("password").is_some() {
|
||||||
|
|||||||
+23
-64
@@ -665,6 +665,7 @@ impl DaemonState {
|
|||||||
let tab_id = mgr.assign_tab_id();
|
let tab_id = mgr.assign_tab_id();
|
||||||
mgr.add_page(super::browser::PageInfo {
|
mgr.add_page(super::browser::PageInfo {
|
||||||
tab_id,
|
tab_id,
|
||||||
|
label: None,
|
||||||
target_id: te.target_info.target_id.clone(),
|
target_id: te.target_info.target_id.clone(),
|
||||||
session_id: attach.session_id,
|
session_id: attach.session_id,
|
||||||
url: te.target_info.url.clone(),
|
url: te.target_info.url.clone(),
|
||||||
@@ -1275,44 +1276,6 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-dispatch: if `tabId` is set on a non-tab command, temporarily switch
|
|
||||||
// to that tab for the duration of the command and restore the original
|
|
||||||
// active tab after. This lets `--tab N <cmd>` target a specific tab
|
|
||||||
// without stealing the user's active-tab context.
|
|
||||||
//
|
|
||||||
// We save the current tab's stable `tab_id` (not its array index) so a
|
|
||||||
// tab close during the scoped command doesn't leave us restoring to a
|
|
||||||
// shifted position. If the saved tab was closed, we skip the restore.
|
|
||||||
let restore_tab_id: Option<u32> = if !matches!(
|
|
||||||
action,
|
|
||||||
"tab_list" | "tab_new" | "tab_switch" | "tab_close" | "launch" | "close"
|
|
||||||
) {
|
|
||||||
if let Some(target_tab_id) = cmd.get("tabId").and_then(|v| v.as_u64()) {
|
|
||||||
let target_tab_id = target_tab_id as u32;
|
|
||||||
let current_tab_id = state.browser.as_ref().and_then(|mgr| mgr.active_tab_id());
|
|
||||||
if current_tab_id == Some(target_tab_id) {
|
|
||||||
// Already on the target tab; nothing to do.
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
// Clear per-tab daemon state before switching so refs from the
|
|
||||||
// outer tab can't resolve against the target tab's DOM.
|
|
||||||
state.ref_map.clear();
|
|
||||||
state.iframe_sessions.clear();
|
|
||||||
state.active_frame_id = None;
|
|
||||||
if let Some(ref mut mgr) = state.browser {
|
|
||||||
if let Err(e) = mgr.tab_switch_by_id(target_tab_id).await {
|
|
||||||
return error_response(&id, &e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
current_tab_id
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = match action {
|
let result = match action {
|
||||||
"launch" => handle_launch(cmd, state).await,
|
"launch" => handle_launch(cmd, state).await,
|
||||||
"navigate" => handle_navigate(cmd, state).await,
|
"navigate" => handle_navigate(cmd, state).await,
|
||||||
@@ -1471,24 +1434,6 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
|||||||
_ => Err(format!("Not yet implemented: {}", action)),
|
_ => Err(format!("Not yet implemented: {}", action)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Post-dispatch: if we temporarily switched tabs for this command, restore
|
|
||||||
// the original active tab. Skip silently if the tab no longer exists (e.g.
|
|
||||||
// the scoped command closed it).
|
|
||||||
if let Some(restore_tab_id) = restore_tab_id {
|
|
||||||
let still_exists = state
|
|
||||||
.browser
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|mgr| mgr.has_tab_id(restore_tab_id));
|
|
||||||
if still_exists {
|
|
||||||
state.ref_map.clear();
|
|
||||||
state.iframe_sessions.clear();
|
|
||||||
state.active_frame_id = None;
|
|
||||||
if let Some(ref mut mgr) = state.browser {
|
|
||||||
let _ = mgr.tab_switch_by_id(restore_tab_id).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut resp = match result {
|
let mut resp = match result {
|
||||||
Ok(data) => success_response(&id, data),
|
Ok(data) => success_response(&id, data),
|
||||||
Err(e) => error_response(&id, &super::browser::to_ai_friendly_error(&e)),
|
Err(e) => error_response(&id, &super::browser::to_ai_friendly_error(&e)),
|
||||||
@@ -1545,7 +1490,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
|||||||
/// subsequent navigations don't hijack the user's existing tabs.
|
/// subsequent navigations don't hijack the user's existing tabs.
|
||||||
async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
|
async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
|
||||||
let mut mgr = BrowserManager::connect_auto().await?;
|
let mut mgr = BrowserManager::connect_auto().await?;
|
||||||
mgr.tab_new(None).await?;
|
mgr.tab_new(None, None).await?;
|
||||||
let session_id = mgr.active_session_id()?.to_string();
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
let _ = mgr
|
let _ = mgr
|
||||||
.client
|
.client
|
||||||
@@ -2648,7 +2593,7 @@ async fn handle_click(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
|
|||||||
|
|
||||||
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
||||||
state.ref_map.clear();
|
state.ref_map.clear();
|
||||||
mgr.tab_new(Some(&href)).await?;
|
mgr.tab_new(Some(&href), None).await?;
|
||||||
|
|
||||||
return Ok(json!({ "clicked": selector, "newTab": true, "url": href }));
|
return Ok(json!({ "clicked": selector, "newTab": true, "url": href }));
|
||||||
}
|
}
|
||||||
@@ -3697,18 +3642,21 @@ async fn handle_tab_list(state: &DaemonState) -> Result<Value, String> {
|
|||||||
async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
||||||
let url = cmd.get("url").and_then(|v| v.as_str());
|
let url = cmd.get("url").and_then(|v| v.as_str());
|
||||||
|
let label = cmd.get("label").and_then(|v| v.as_str());
|
||||||
state.ref_map.clear();
|
state.ref_map.clear();
|
||||||
state.iframe_sessions.clear();
|
state.iframe_sessions.clear();
|
||||||
state.active_frame_id = None;
|
state.active_frame_id = None;
|
||||||
mgr.tab_new(url).await
|
mgr.tab_new(url, label).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
||||||
let tab_id = cmd
|
let tab_ref_str = cmd
|
||||||
.get("tabId")
|
.get("tabId")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or("Missing 'tabId' parameter")? as u32;
|
.ok_or("Missing 'tabId' parameter (expected `t<N>` or a label)")?;
|
||||||
|
let tab_ref = super::browser::TabRef::parse(tab_ref_str)?;
|
||||||
|
let tab_id = mgr.resolve_tab_ref(&tab_ref)?;
|
||||||
state.ref_map.clear();
|
state.ref_map.clear();
|
||||||
state.iframe_sessions.clear();
|
state.iframe_sessions.clear();
|
||||||
state.active_frame_id = None;
|
state.active_frame_id = None;
|
||||||
@@ -3737,7 +3685,13 @@ async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
|||||||
|
|
||||||
async fn handle_tab_close(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_tab_close(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
||||||
let tab_id = cmd.get("tabId").and_then(|v| v.as_u64()).map(|i| i as u32);
|
let tab_id = match cmd.get("tabId").and_then(|v| v.as_str()) {
|
||||||
|
Some(s) => {
|
||||||
|
let tab_ref = super::browser::TabRef::parse(s)?;
|
||||||
|
Some(mgr.resolve_tab_ref(&tab_ref)?)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
state.ref_map.clear();
|
state.ref_map.clear();
|
||||||
state.iframe_sessions.clear();
|
state.iframe_sessions.clear();
|
||||||
state.active_frame_id = None;
|
state.active_frame_id = None;
|
||||||
@@ -4123,6 +4077,7 @@ async fn handle_recording_start(cmd: &Value, state: &mut DaemonState) -> Result<
|
|||||||
let tab_id = mgr.assign_tab_id();
|
let tab_id = mgr.assign_tab_id();
|
||||||
mgr.add_page(super::browser::PageInfo {
|
mgr.add_page(super::browser::PageInfo {
|
||||||
tab_id,
|
tab_id,
|
||||||
|
label: None,
|
||||||
target_id: create_result.target_id,
|
target_id: create_result.target_id,
|
||||||
session_id: new_session_id.clone(),
|
session_id: new_session_id.clone(),
|
||||||
url: nav_url.clone(),
|
url: nav_url.clone(),
|
||||||
@@ -6036,6 +5991,7 @@ async fn handle_window_new(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
|||||||
let tab_id = mgr.assign_tab_id();
|
let tab_id = mgr.assign_tab_id();
|
||||||
mgr.add_page(super::browser::PageInfo {
|
mgr.add_page(super::browser::PageInfo {
|
||||||
tab_id,
|
tab_id,
|
||||||
|
label: None,
|
||||||
target_id: create_result.target_id,
|
target_id: create_result.target_id,
|
||||||
session_id: attach.session_id,
|
session_id: attach.session_id,
|
||||||
url: "about:blank".to_string(),
|
url: "about:blank".to_string(),
|
||||||
@@ -6063,7 +6019,10 @@ async fn handle_window_new(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
|||||||
let total = mgr.page_count();
|
let total = mgr.page_count();
|
||||||
state.ref_map.clear();
|
state.ref_map.clear();
|
||||||
|
|
||||||
Ok(json!({ "tabId": tab_id, "total": total }))
|
Ok(json!({
|
||||||
|
"tabId": super::browser::format_tab_id(tab_id),
|
||||||
|
"total": total,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_diff_screenshot(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
async fn handle_diff_screenshot(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||||
|
|||||||
+229
-6
@@ -159,6 +159,12 @@ pub fn to_ai_friendly_error(error: &str) -> String {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PageInfo {
|
pub struct PageInfo {
|
||||||
pub tab_id: u32,
|
pub tab_id: u32,
|
||||||
|
/// Optional user-assigned label (e.g. "docs", "app"). Set via
|
||||||
|
/// `tab new --label <name>`. Labels are agent-assigned and never
|
||||||
|
/// auto-generated, never rewritten on navigation, and unique within a
|
||||||
|
/// session. Agents use labels instead of `t<N>` for readable multi-tab
|
||||||
|
/// workflows.
|
||||||
|
pub label: Option<String>,
|
||||||
pub target_id: String,
|
pub target_id: String,
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
@@ -166,6 +172,77 @@ pub struct PageInfo {
|
|||||||
pub target_type: String, // "page" or "webview"
|
pub target_type: String, // "page" or "webview"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Canonical string form of a stable tab id: `t1`, `t2`, ... The `t` prefix
|
||||||
|
/// disambiguates stable ids from positional indices (which the CLI no longer
|
||||||
|
/// accepts) and matches the `@e<N>` convention used for element refs.
|
||||||
|
pub fn format_tab_id(tab_id: u32) -> String {
|
||||||
|
format!("t{}", tab_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tab reference as parsed from CLI/JSON input. Either a stable id like
|
||||||
|
/// `t2` or a user-assigned label like `docs`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TabRef {
|
||||||
|
Id(u32),
|
||||||
|
Label(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TabRef {
|
||||||
|
/// Parse a user-supplied string tab reference. Rejects bare integers
|
||||||
|
/// with a teaching error so agents and scripts don't silently confuse
|
||||||
|
/// stable ids with positional indices.
|
||||||
|
pub fn parse(input: &str) -> Result<Self, String> {
|
||||||
|
let input = input.trim();
|
||||||
|
if input.is_empty() {
|
||||||
|
return Err("Empty tab reference; expected `t<N>` (e.g. `t2`) or a label".to_string());
|
||||||
|
}
|
||||||
|
if let Some(digits) = input.strip_prefix('t').or_else(|| input.strip_prefix('T')) {
|
||||||
|
if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
let id: u32 = digits.parse().map_err(|_| {
|
||||||
|
format!(
|
||||||
|
"Tab id `{}` out of range; ids are incrementing positive integers",
|
||||||
|
input
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if id == 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"Tab id `{}` is invalid; tab ids start at t1",
|
||||||
|
input
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(TabRef::Id(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
return Err(format!(
|
||||||
|
"Expected a tab id like `t{}` or a label; positional integers are not accepted \
|
||||||
|
(run `agent-browser tab` to list stable tab ids)",
|
||||||
|
input
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !is_valid_label(input) {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid tab label `{}`; labels must start with a letter and contain only \
|
||||||
|
letters, digits, `-`, and `_`",
|
||||||
|
input
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(TabRef::Label(input.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Labels must look like identifiers: start with a letter, contain only
|
||||||
|
/// letters/digits/dashes/underscores. This keeps them distinguishable from
|
||||||
|
/// `t<N>` ids at a glance and safe to pass through shells without quoting.
|
||||||
|
pub fn is_valid_label(s: &str) -> bool {
|
||||||
|
let mut chars = s.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(c) if c.is_ascii_alphabetic() => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum WaitUntil {
|
pub enum WaitUntil {
|
||||||
Load,
|
Load,
|
||||||
@@ -395,6 +472,7 @@ impl BrowserManager {
|
|||||||
let tab_id = manager.assign_tab_id();
|
let tab_id = manager.assign_tab_id();
|
||||||
manager.pages.push(PageInfo {
|
manager.pages.push(PageInfo {
|
||||||
tab_id,
|
tab_id,
|
||||||
|
label: None,
|
||||||
target_id: "provider-page".to_string(),
|
target_id: "provider-page".to_string(),
|
||||||
session_id: String::new(),
|
session_id: String::new(),
|
||||||
url: String::new(),
|
url: String::new(),
|
||||||
@@ -463,6 +541,7 @@ impl BrowserManager {
|
|||||||
self.next_tab_id += 1;
|
self.next_tab_id += 1;
|
||||||
self.pages.push(PageInfo {
|
self.pages.push(PageInfo {
|
||||||
tab_id,
|
tab_id,
|
||||||
|
label: None,
|
||||||
target_id: result.target_id,
|
target_id: result.target_id,
|
||||||
session_id: attach_result.session_id.clone(),
|
session_id: attach_result.session_id.clone(),
|
||||||
url: "about:blank".to_string(),
|
url: "about:blank".to_string(),
|
||||||
@@ -489,6 +568,7 @@ impl BrowserManager {
|
|||||||
self.next_tab_id += 1;
|
self.next_tab_id += 1;
|
||||||
self.pages.push(PageInfo {
|
self.pages.push(PageInfo {
|
||||||
tab_id,
|
tab_id,
|
||||||
|
label: None,
|
||||||
target_id: target.target_id.clone(),
|
target_id: target.target_id.clone(),
|
||||||
session_id: attach_result.session_id.clone(),
|
session_id: attach_result.session_id.clone(),
|
||||||
url: target.url.clone(),
|
url: target.url.clone(),
|
||||||
@@ -837,6 +917,7 @@ impl BrowserManager {
|
|||||||
self.next_tab_id += 1;
|
self.next_tab_id += 1;
|
||||||
self.pages.push(PageInfo {
|
self.pages.push(PageInfo {
|
||||||
tab_id,
|
tab_id,
|
||||||
|
label: None,
|
||||||
target_id: result.target_id,
|
target_id: result.target_id,
|
||||||
session_id: attach_result.session_id.clone(),
|
session_id: attach_result.session_id.clone(),
|
||||||
url: "about:blank".to_string(),
|
url: "about:blank".to_string(),
|
||||||
@@ -879,7 +960,8 @@ impl BrowserManager {
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, p)| {
|
.map(|(i, p)| {
|
||||||
json!({
|
json!({
|
||||||
"tabId": p.tab_id,
|
"tabId": format_tab_id(p.tab_id),
|
||||||
|
"label": p.label,
|
||||||
"title": p.title,
|
"title": p.title,
|
||||||
"url": p.url,
|
"url": p.url,
|
||||||
"type": p.target_type,
|
"type": p.target_type,
|
||||||
@@ -889,7 +971,61 @@ impl BrowserManager {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn tab_new(&mut self, url: Option<&str>) -> Result<Value, String> {
|
/// Resolve a user-supplied `TabRef` (either `t<N>` or a label) to the
|
||||||
|
/// stable numeric `tab_id`. Returns a teaching error for unknown tabs.
|
||||||
|
pub fn resolve_tab_ref(&self, tab_ref: &TabRef) -> Result<u32, String> {
|
||||||
|
match tab_ref {
|
||||||
|
TabRef::Id(id) => {
|
||||||
|
if self.has_tab_id(*id) {
|
||||||
|
Ok(*id)
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"Tab {} not found; run `agent-browser tab` to list open tabs",
|
||||||
|
format_tab_id(*id)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TabRef::Label(name) => self
|
||||||
|
.pages
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.label.as_deref() == Some(name.as_str()))
|
||||||
|
.map(|p| p.tab_id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"No tab with label `{}`; run `agent-browser tab` to list open tabs",
|
||||||
|
name
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true iff a tab already carries the given label.
|
||||||
|
pub fn has_label(&self, label: &str) -> bool {
|
||||||
|
self.pages.iter().any(|p| p.label.as_deref() == Some(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tab_new(
|
||||||
|
&mut self,
|
||||||
|
url: Option<&str>,
|
||||||
|
label: Option<&str>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
if let Some(label) = label {
|
||||||
|
if !is_valid_label(label) {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid tab label `{}`; labels must start with a letter and contain only \
|
||||||
|
letters, digits, `-`, and `_`",
|
||||||
|
label
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.has_label(label) {
|
||||||
|
return Err(format!(
|
||||||
|
"Label `{}` is already used by another tab; labels must be unique within a \
|
||||||
|
session",
|
||||||
|
label
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let target_url = url.unwrap_or("about:blank");
|
let target_url = url.unwrap_or("about:blank");
|
||||||
|
|
||||||
let result: CreateTargetResult = self
|
let result: CreateTargetResult = self
|
||||||
@@ -920,8 +1056,10 @@ impl BrowserManager {
|
|||||||
let tab_id = self.next_tab_id;
|
let tab_id = self.next_tab_id;
|
||||||
self.next_tab_id += 1;
|
self.next_tab_id += 1;
|
||||||
let index = self.pages.len();
|
let index = self.pages.len();
|
||||||
|
let label = label.map(|s| s.to_string());
|
||||||
self.pages.push(PageInfo {
|
self.pages.push(PageInfo {
|
||||||
tab_id,
|
tab_id,
|
||||||
|
label: label.clone(),
|
||||||
target_id: result.target_id,
|
target_id: result.target_id,
|
||||||
session_id: attach.session_id,
|
session_id: attach.session_id,
|
||||||
url: target_url.to_string(),
|
url: target_url.to_string(),
|
||||||
@@ -930,7 +1068,12 @@ impl BrowserManager {
|
|||||||
});
|
});
|
||||||
self.active_page_index = index;
|
self.active_page_index = index;
|
||||||
|
|
||||||
Ok(json!({ "tabId": tab_id, "url": target_url, "total": self.pages.len() }))
|
Ok(json!({
|
||||||
|
"tabId": format_tab_id(tab_id),
|
||||||
|
"label": label,
|
||||||
|
"url": target_url,
|
||||||
|
"total": self.pages.len(),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn tab_switch(&mut self, index: usize) -> Result<Value, String> {
|
pub async fn tab_switch(&mut self, index: usize) -> Result<Value, String> {
|
||||||
@@ -960,8 +1103,13 @@ impl BrowserManager {
|
|||||||
page.title = title.clone();
|
page.title = title.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
let tab_id = self.pages[index].tab_id;
|
let page = &self.pages[index];
|
||||||
Ok(json!({ "tabId": tab_id, "url": url, "title": title }))
|
Ok(json!({
|
||||||
|
"tabId": format_tab_id(page.tab_id),
|
||||||
|
"label": page.label,
|
||||||
|
"url": url,
|
||||||
|
"title": title,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn tab_close(&mut self, index: Option<usize>) -> Result<Value, String> {
|
pub async fn tab_close(&mut self, index: Option<usize>) -> Result<Value, String> {
|
||||||
@@ -978,6 +1126,7 @@ impl BrowserManager {
|
|||||||
let page = self.pages.remove(target_index);
|
let page = self.pages.remove(target_index);
|
||||||
self.update_active_page_after_removal(target_index);
|
self.update_active_page_after_removal(target_index);
|
||||||
let closed_tab_id = page.tab_id;
|
let closed_tab_id = page.tab_id;
|
||||||
|
let closed_label = page.label.clone();
|
||||||
let _ = self
|
let _ = self
|
||||||
.client
|
.client
|
||||||
.send_command_typed::<_, Value>(
|
.send_command_typed::<_, Value>(
|
||||||
@@ -992,7 +1141,11 @@ impl BrowserManager {
|
|||||||
let session_id = self.pages[self.active_page_index].session_id.clone();
|
let session_id = self.pages[self.active_page_index].session_id.clone();
|
||||||
self.enable_domains(&session_id).await?;
|
self.enable_domains(&session_id).await?;
|
||||||
|
|
||||||
Ok(json!({ "tabId": closed_tab_id, "closed": true }))
|
Ok(json!({
|
||||||
|
"tabId": format_tab_id(closed_tab_id),
|
||||||
|
"label": closed_label,
|
||||||
|
"closed": true,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -1557,6 +1710,75 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_tab_id() {
|
||||||
|
assert_eq!(format_tab_id(1), "t1");
|
||||||
|
assert_eq!(format_tab_id(42), "t42");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_tab_ref_id() {
|
||||||
|
assert_eq!(TabRef::parse("t1"), Ok(TabRef::Id(1)));
|
||||||
|
assert_eq!(TabRef::parse("t42"), Ok(TabRef::Id(42)));
|
||||||
|
assert_eq!(TabRef::parse("T7"), Ok(TabRef::Id(7)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_tab_ref_label() {
|
||||||
|
assert_eq!(TabRef::parse("docs"), Ok(TabRef::Label("docs".to_string())));
|
||||||
|
assert_eq!(
|
||||||
|
TabRef::parse("app-2"),
|
||||||
|
Ok(TabRef::Label("app-2".to_string()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
TabRef::parse("my_tab"),
|
||||||
|
Ok(TabRef::Label("my_tab".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_tab_ref_rejects_bare_integer() {
|
||||||
|
let err = TabRef::parse("2").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("positional integers are not accepted"),
|
||||||
|
"error should teach the user to use `t<N>`: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
assert!(err.contains("t2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_tab_ref_rejects_empty() {
|
||||||
|
assert!(TabRef::parse("").is_err());
|
||||||
|
assert!(TabRef::parse(" ").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_tab_ref_rejects_zero() {
|
||||||
|
let err = TabRef::parse("t0").unwrap_err();
|
||||||
|
assert!(err.contains("start at t1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_tab_ref_rejects_invalid_label() {
|
||||||
|
assert!(TabRef::parse("2docs").is_err());
|
||||||
|
assert!(TabRef::parse("-docs").is_err());
|
||||||
|
assert!(TabRef::parse("docs!").is_err());
|
||||||
|
assert!(TabRef::parse("docs space").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_valid_label() {
|
||||||
|
assert!(is_valid_label("docs"));
|
||||||
|
assert!(is_valid_label("Docs"));
|
||||||
|
assert!(is_valid_label("app-2"));
|
||||||
|
assert!(is_valid_label("my_tab"));
|
||||||
|
assert!(!is_valid_label(""));
|
||||||
|
assert!(!is_valid_label("2docs"));
|
||||||
|
assert!(!is_valid_label("-docs"));
|
||||||
|
assert!(!is_valid_label("docs!"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_track_popup_target_with_empty_url() {
|
fn test_should_track_popup_target_with_empty_url() {
|
||||||
let target = TargetInfo {
|
let target = TargetInfo {
|
||||||
@@ -1589,6 +1811,7 @@ mod tests {
|
|||||||
fn test_update_page_target_info_in_pages_updates_existing_page() {
|
fn test_update_page_target_info_in_pages_updates_existing_page() {
|
||||||
let mut pages = vec![PageInfo {
|
let mut pages = vec![PageInfo {
|
||||||
tab_id: 1,
|
tab_id: 1,
|
||||||
|
label: None,
|
||||||
target_id: "popup-1".to_string(),
|
target_id: "popup-1".to_string(),
|
||||||
session_id: "session-1".to_string(),
|
session_id: "session-1".to_string(),
|
||||||
url: String::new(),
|
url: String::new(),
|
||||||
|
|||||||
+194
-400
@@ -911,7 +911,7 @@ async fn e2e_tabs() {
|
|||||||
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
||||||
assert_eq!(tabs.len(), 1);
|
assert_eq!(tabs.len(), 1);
|
||||||
assert_eq!(tabs[0]["active"], true);
|
assert_eq!(tabs[0]["active"], true);
|
||||||
assert_eq!(tabs[0]["tabId"], 1, "First tab should have tabId 1");
|
assert_eq!(tabs[0]["tabId"], "t1", "First tab should have tabId t1");
|
||||||
|
|
||||||
// Open new tab
|
// Open new tab
|
||||||
let resp = execute_command(
|
let resp = execute_command(
|
||||||
@@ -920,7 +920,11 @@ async fn e2e_tabs() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_success(&resp);
|
assert_success(&resp);
|
||||||
assert_eq!(get_data(&resp)["tabId"], 2, "New tab should have tabId 2");
|
assert_eq!(
|
||||||
|
get_data(&resp)["tabId"],
|
||||||
|
"t2",
|
||||||
|
"New tab should have tabId t2"
|
||||||
|
);
|
||||||
assert_eq!(get_data(&resp)["total"], 2);
|
assert_eq!(get_data(&resp)["total"], 2);
|
||||||
|
|
||||||
// Tab list should show 2 tabs with distinct, incrementing tabIds
|
// Tab list should show 2 tabs with distinct, incrementing tabIds
|
||||||
@@ -929,12 +933,12 @@ async fn e2e_tabs() {
|
|||||||
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
||||||
assert_eq!(tabs.len(), 2);
|
assert_eq!(tabs.len(), 2);
|
||||||
assert_eq!(tabs[1]["active"], true);
|
assert_eq!(tabs[1]["active"], true);
|
||||||
assert_eq!(tabs[0]["tabId"], 1, "First tab should keep tabId 1");
|
assert_eq!(tabs[0]["tabId"], "t1", "First tab should keep tabId t1");
|
||||||
assert_eq!(tabs[1]["tabId"], 2, "Second tab should have tabId 2");
|
assert_eq!(tabs[1]["tabId"], "t2", "Second tab should have tabId t2");
|
||||||
|
|
||||||
// Switch to first tab
|
// Switch to first tab
|
||||||
let resp = execute_command(
|
let resp = execute_command(
|
||||||
&json!({ "id": "6", "action": "tab_switch", "tabId": 1 }),
|
&json!({ "id": "6", "action": "tab_switch", "tabId": "t1" }),
|
||||||
&mut state,
|
&mut state,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -950,7 +954,7 @@ async fn e2e_tabs() {
|
|||||||
|
|
||||||
// Close second tab
|
// Close second tab
|
||||||
let resp = execute_command(
|
let resp = execute_command(
|
||||||
&json!({ "id": "8", "action": "tab_close", "tabId": 2 }),
|
&json!({ "id": "8", "action": "tab_close", "tabId": "t2" }),
|
||||||
&mut state,
|
&mut state,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -982,7 +986,7 @@ async fn e2e_tab_ids_not_reused() {
|
|||||||
let resp = execute_command(&json!({ "id": "2", "action": "tab_list" }), &mut state).await;
|
let resp = execute_command(&json!({ "id": "2", "action": "tab_list" }), &mut state).await;
|
||||||
assert_success(&resp);
|
assert_success(&resp);
|
||||||
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
||||||
assert_eq!(tabs[0]["tabId"], 1);
|
assert_eq!(tabs[0]["tabId"], "t1");
|
||||||
|
|
||||||
// Open tab 2 and tab 3
|
// Open tab 2 and tab 3
|
||||||
let resp = execute_command(
|
let resp = execute_command(
|
||||||
@@ -991,7 +995,7 @@ async fn e2e_tab_ids_not_reused() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_success(&resp);
|
assert_success(&resp);
|
||||||
assert_eq!(get_data(&resp)["tabId"], 2);
|
assert_eq!(get_data(&resp)["tabId"], "t2");
|
||||||
|
|
||||||
let resp = execute_command(
|
let resp = execute_command(
|
||||||
&json!({ "id": "4", "action": "tab_new", "url": "data:text/html,<h1>Tab 3</h1>" }),
|
&json!({ "id": "4", "action": "tab_new", "url": "data:text/html,<h1>Tab 3</h1>" }),
|
||||||
@@ -999,11 +1003,11 @@ async fn e2e_tab_ids_not_reused() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_success(&resp);
|
assert_success(&resp);
|
||||||
assert_eq!(get_data(&resp)["tabId"], 3);
|
assert_eq!(get_data(&resp)["tabId"], "t3");
|
||||||
|
|
||||||
// Close tab 2
|
// Close tab 2
|
||||||
let resp = execute_command(
|
let resp = execute_command(
|
||||||
&json!({ "id": "5", "action": "tab_close", "tabId": 2 }),
|
&json!({ "id": "5", "action": "tab_close", "tabId": "t2" }),
|
||||||
&mut state,
|
&mut state,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1018,406 +1022,31 @@ async fn e2e_tab_ids_not_reused() {
|
|||||||
assert_success(&resp);
|
assert_success(&resp);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
get_data(&resp)["tabId"],
|
get_data(&resp)["tabId"],
|
||||||
4,
|
"t4",
|
||||||
"Tab IDs must not be reused after closing"
|
"Tab IDs must not be reused after closing"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify final state: tabs 1, 3, 4
|
// Verify final state: tabs t1, t3, t4
|
||||||
let resp = execute_command(&json!({ "id": "7", "action": "tab_list" }), &mut state).await;
|
let resp = execute_command(&json!({ "id": "7", "action": "tab_list" }), &mut state).await;
|
||||||
assert_success(&resp);
|
assert_success(&resp);
|
||||||
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
||||||
assert_eq!(tabs.len(), 3);
|
assert_eq!(tabs.len(), 3);
|
||||||
let ids: Vec<i64> = tabs.iter().map(|t| t["tabId"].as_i64().unwrap()).collect();
|
let ids: Vec<String> = tabs
|
||||||
assert_eq!(ids, vec![1, 3, 4]);
|
.iter()
|
||||||
|
.map(|t| t["tabId"].as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(ids, vec!["t1", "t3", "t4"]);
|
||||||
|
|
||||||
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||||
assert_success(&resp);
|
assert_success(&resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `tab_close` with an explicit `tabId` must close that tab regardless of
|
||||||
|
/// whether it's active, and leave the remaining tab active without leaking
|
||||||
|
/// per-tab state (refs, iframe sessions, frame id) from the closed tab.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore]
|
#[ignore]
|
||||||
async fn e2e_tab_global_targeting() {
|
async fn e2e_tab_close_with_tab_id_closes_active_tab() {
|
||||||
let mut state = DaemonState::new();
|
|
||||||
|
|
||||||
let resp = execute_command(
|
|
||||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
|
||||||
&mut state,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_success(&resp);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
// Active tab was never changed by the scoped `tabId` commands
|
|
||||||
// (restoration semantics), so it's still tab 2 from the earlier `tab_new`.
|
|
||||||
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 uses the still-active tab 2 (restoration
|
|
||||||
// semantics: scoped commands don't change the active tab).
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// `--tab` / `tabId` scoped-command regression tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `tabId`-scoped commands must clear `state.ref_map`, `state.iframe_sessions`,
|
|
||||||
/// and `state.active_frame_id` when they temporarily switch tabs, otherwise
|
|
||||||
/// refs from the outer tab would resolve against the scoped tab's DOM.
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore]
|
|
||||||
async fn e2e_tab_scoped_command_clears_state_on_switch() {
|
|
||||||
let mut state = DaemonState::new();
|
|
||||||
|
|
||||||
let resp = execute_command(
|
|
||||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
|
||||||
&mut state,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_success(&resp);
|
|
||||||
|
|
||||||
let resp = execute_command(
|
|
||||||
&json!({ "id": "2", "action": "navigate", "url": "data:text/html,<button>Alpha</button>" }),
|
|
||||||
&mut state,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_success(&resp);
|
|
||||||
|
|
||||||
let resp = execute_command(
|
|
||||||
&json!({ "id": "3", "action": "tab_new", "url": "data:text/html,<p>Beta</p>" }),
|
|
||||||
&mut state,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_success(&resp);
|
|
||||||
assert_eq!(get_data(&resp)["tabId"], 2);
|
|
||||||
|
|
||||||
let resp = execute_command(
|
|
||||||
&json!({ "id": "4", "action": "tab_switch", "tabId": 1 }),
|
|
||||||
&mut state,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_success(&resp);
|
|
||||||
|
|
||||||
let resp = execute_command(&json!({ "id": "5", "action": "snapshot" }), &mut state).await;
|
|
||||||
assert_success(&resp);
|
|
||||||
assert!(
|
|
||||||
state.ref_map.get("e1").is_some(),
|
|
||||||
"snapshot should populate @e1 on tab 1"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Run a tabId-scoped command. The pre-dispatch must clear per-tab state
|
|
||||||
// so nothing can leak into the scoped tab's context.
|
|
||||||
let resp = execute_command(
|
|
||||||
&json!({ "id": "6", "action": "title", "tabId": 2 }),
|
|
||||||
&mut state,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_success(&resp);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
state.ref_map.get("e1").is_none(),
|
|
||||||
"ref_map must be cleared when a tabId-scoped command switches tabs, \
|
|
||||||
but @e1 is still present: {:?}",
|
|
||||||
state.ref_map.get("e1")
|
|
||||||
);
|
|
||||||
assert!(state.iframe_sessions.is_empty());
|
|
||||||
assert!(state.active_frame_id.is_none());
|
|
||||||
|
|
||||||
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
|
||||||
assert_success(&resp);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `tabId`-scoped commands must restore the original active tab afterward so
|
|
||||||
/// `--tab N` is a non-intrusive peek that doesn't change the user's context.
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore]
|
|
||||||
async fn e2e_tab_scoped_command_restores_active_tab() {
|
|
||||||
let mut state = DaemonState::new();
|
|
||||||
|
|
||||||
let resp = execute_command(
|
|
||||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
|
||||||
&mut state,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_success(&resp);
|
|
||||||
|
|
||||||
let resp = execute_command(
|
|
||||||
&json!({ "id": "2", "action": "navigate", "url": "data:text/html,<title>A</title>" }),
|
|
||||||
&mut state,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_success(&resp);
|
|
||||||
|
|
||||||
let resp = execute_command(
|
|
||||||
&json!({ "id": "3", "action": "tab_new", "url": "data:text/html,<title>B</title>" }),
|
|
||||||
&mut state,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_success(&resp);
|
|
||||||
assert_eq!(get_data(&resp)["tabId"], 2);
|
|
||||||
|
|
||||||
// Active tab is 2. Peek at tab 1 with a scoped command.
|
|
||||||
let resp = execute_command(
|
|
||||||
&json!({ "id": "4", "action": "title", "tabId": 1 }),
|
|
||||||
&mut state,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_success(&resp);
|
|
||||||
assert_eq!(get_data(&resp)["title"], "A", "tabId should route to tab 1");
|
|
||||||
|
|
||||||
// No tabId: must reflect the originally active tab (tab 2).
|
|
||||||
let resp = execute_command(&json!({ "id": "5", "action": "title" }), &mut state).await;
|
|
||||||
assert_success(&resp);
|
|
||||||
assert_eq!(
|
|
||||||
get_data(&resp)["title"],
|
|
||||||
"B",
|
|
||||||
"active tab should be restored to tab 2 after the scoped command; \
|
|
||||||
got the scoped tab's title instead"
|
|
||||||
);
|
|
||||||
|
|
||||||
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
|
||||||
assert_success(&resp);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restoration must be skipped (without error) if the scoped command closes
|
|
||||||
/// the tab that was active before the switch.
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore]
|
|
||||||
async fn e2e_tab_scoped_command_handles_outer_tab_closed() {
|
|
||||||
let mut state = DaemonState::new();
|
let mut state = DaemonState::new();
|
||||||
|
|
||||||
let resp = execute_command(
|
let resp = execute_command(
|
||||||
@@ -1441,11 +1070,8 @@ async fn e2e_tab_scoped_command_handles_outer_tab_closed() {
|
|||||||
.await;
|
.await;
|
||||||
assert_success(&resp);
|
assert_success(&resp);
|
||||||
|
|
||||||
// Active tab is 2. Peek at tab 1 with a command that also closes tab 2.
|
|
||||||
// The restoration path must not error when it discovers tab 2 is gone;
|
|
||||||
// we treat "outer tab vanished" as an implicit accept of the scoped tab.
|
|
||||||
let resp = execute_command(
|
let resp = execute_command(
|
||||||
&json!({ "id": "4", "action": "tab_close", "tabId": 2 }),
|
&json!({ "id": "4", "action": "tab_close", "tabId": "t2" }),
|
||||||
&mut state,
|
&mut state,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1454,6 +1080,174 @@ async fn e2e_tab_scoped_command_handles_outer_tab_closed() {
|
|||||||
let resp = execute_command(&json!({ "id": "5", "action": "title" }), &mut state).await;
|
let resp = execute_command(&json!({ "id": "5", "action": "title" }), &mut state).await;
|
||||||
assert_success(&resp);
|
assert_success(&resp);
|
||||||
assert_eq!(get_data(&resp)["title"], "A");
|
assert_eq!(get_data(&resp)["title"], "A");
|
||||||
|
assert!(state.ref_map.get("e1").is_none());
|
||||||
|
assert!(state.iframe_sessions.is_empty());
|
||||||
|
assert!(state.active_frame_id.is_none());
|
||||||
|
|
||||||
|
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||||
|
assert_success(&resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tabs can be opened with a user-assigned label and then addressed by that
|
||||||
|
/// label anywhere a `t<N>` id is accepted (switch, close, and JSON `tabId`
|
||||||
|
/// on `tab_switch` / `tab_close`). Labels are the agent-friendly way to
|
||||||
|
/// write multi-tab workflows without memorizing ids.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn e2e_tab_new_with_label_can_be_switched_and_closed() {
|
||||||
|
let mut state = DaemonState::new();
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "2", "action": "navigate", "url": "data:text/html,<title>Home</title>" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
|
||||||
|
// Open a labeled tab and verify the response echoes the label and a
|
||||||
|
// `t<N>` style tabId.
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({
|
||||||
|
"id": "3",
|
||||||
|
"action": "tab_new",
|
||||||
|
"url": "data:text/html,<title>Docs</title>",
|
||||||
|
"label": "docs",
|
||||||
|
}),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(get_data(&resp)["tabId"], "t2");
|
||||||
|
assert_eq!(get_data(&resp)["label"], "docs");
|
||||||
|
|
||||||
|
// tab_list exposes the label alongside the id.
|
||||||
|
let resp = execute_command(&json!({ "id": "4", "action": "tab_list" }), &mut state).await;
|
||||||
|
assert_success(&resp);
|
||||||
|
let tabs = get_data(&resp)["tabs"].as_array().unwrap();
|
||||||
|
let docs = tabs
|
||||||
|
.iter()
|
||||||
|
.find(|t| t["tabId"] == "t2")
|
||||||
|
.expect("docs tab should be present");
|
||||||
|
assert_eq!(docs["label"], "docs");
|
||||||
|
|
||||||
|
// tab_switch accepts the label.
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "5", "action": "tab_switch", "tabId": "t1" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(state.browser.as_ref().unwrap().active_tab_id(), Some(1));
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "6", "action": "tab_switch", "tabId": "docs" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(state.browser.as_ref().unwrap().active_tab_id(), Some(2));
|
||||||
|
|
||||||
|
// Once switched, the active tab is the labeled one and normal commands
|
||||||
|
// work against it.
|
||||||
|
let resp = execute_command(&json!({ "id": "7", "action": "title" }), &mut state).await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(get_data(&resp)["title"], "Docs");
|
||||||
|
|
||||||
|
// tab_close accepts the label.
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "8", "action": "tab_close", "tabId": "docs" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(get_data(&resp)["label"], "docs");
|
||||||
|
|
||||||
|
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||||
|
assert_success(&resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duplicate labels must be rejected so agents can treat a label as a unique
|
||||||
|
/// handle. The first tab keeps the label; the second tab's creation errors.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn e2e_tab_new_with_duplicate_label_errors() {
|
||||||
|
let mut state = DaemonState::new();
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "2", "action": "tab_new", "url": "about:blank", "label": "docs" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "3", "action": "tab_new", "url": "about:blank", "label": "docs" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
resp.get("success").and_then(|v| v.as_bool()),
|
||||||
|
Some(false),
|
||||||
|
"duplicate label should error: {}",
|
||||||
|
serde_json::to_string_pretty(&resp).unwrap_or_default()
|
||||||
|
);
|
||||||
|
let err = resp.get("error").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
assert!(
|
||||||
|
err.contains("already used"),
|
||||||
|
"error should explain the collision: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||||
|
assert_success(&resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Positional integers passed as `tabId` on tab-switch / tab-close must be
|
||||||
|
/// rejected by the daemon-layer parser, not silently coerced. The error
|
||||||
|
/// should teach the user the correct form (`t<N>`).
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn e2e_tab_switch_rejects_bare_integer() {
|
||||||
|
let mut state = DaemonState::new();
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "2", "action": "tab_switch", "tabId": "2" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
resp.get("success").and_then(|v| v.as_bool()),
|
||||||
|
Some(false),
|
||||||
|
"bare integer tabId on tab_switch should error: {}",
|
||||||
|
serde_json::to_string_pretty(&resp).unwrap_or_default()
|
||||||
|
);
|
||||||
|
let err = resp.get("error").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
assert!(
|
||||||
|
err.contains("t2") && err.contains("positional integers"),
|
||||||
|
"error should teach `t<N>` convention: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
|
||||||
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||||
assert_success(&resp);
|
assert_success(&resp);
|
||||||
|
|||||||
+45
-75
@@ -407,10 +407,8 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
// Tabs
|
// Tabs
|
||||||
if let Some(tabs) = data.get("tabs").and_then(|v| v.as_array()) {
|
if let Some(tabs) = data.get("tabs").and_then(|v| v.as_array()) {
|
||||||
for tab in tabs {
|
for tab in tabs {
|
||||||
let tab_id = tab
|
let tab_id = tab.get("tabId").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
.get("tabId")
|
let tab_label = tab.get("label").and_then(|v| v.as_str());
|
||||||
.and_then(|v| v.as_i64())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let title = tab
|
let title = tab
|
||||||
.get("title")
|
.get("title")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -422,13 +420,17 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
} else {
|
} else {
|
||||||
" ".to_string()
|
" ".to_string()
|
||||||
};
|
};
|
||||||
println!("{} [{}] {} - {}", marker, tab_id, title, url);
|
if let Some(label) = tab_label {
|
||||||
|
println!("{} [{}] {} {} - {}", marker, tab_id, label, title, url);
|
||||||
|
} else {
|
||||||
|
println!("{} [{}] {} - {}", marker, tab_id, title, url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Tab switch
|
// Tab switch
|
||||||
if action == Some("tab_switch") {
|
if action == Some("tab_switch") {
|
||||||
if let Some(tab_id) = data.get("tabId").and_then(|v| v.as_i64()) {
|
if let Some(tab_id) = data.get("tabId").and_then(|v| v.as_str()) {
|
||||||
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
||||||
println!(
|
println!(
|
||||||
"{} Switched to tab [{}] ({})",
|
"{} Switched to tab [{}] ({})",
|
||||||
@@ -447,19 +449,31 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// New tab/window
|
// New tab/window
|
||||||
if let Some(tab_id) = data.get("tabId").and_then(|v| v.as_i64()) {
|
if let Some(tab_id) = data.get("tabId").and_then(|v| v.as_str()) {
|
||||||
if let Some(total) = data.get("total").and_then(|v| v.as_i64()) {
|
if let Some(total) = data.get("total").and_then(|v| v.as_i64()) {
|
||||||
let label = match action {
|
let label_noun = match action {
|
||||||
Some("window_new") => "Window opened",
|
Some("window_new") => "Window opened",
|
||||||
_ => "Tab opened",
|
_ => "Tab opened",
|
||||||
};
|
};
|
||||||
println!(
|
let tab_label = data.get("label").and_then(|v| v.as_str());
|
||||||
"{} {} [{}] ({} total)",
|
if let Some(lbl) = tab_label {
|
||||||
color::success_indicator(),
|
println!(
|
||||||
label,
|
"{} {} [{}] {} ({} total)",
|
||||||
tab_id,
|
color::success_indicator(),
|
||||||
total
|
label_noun,
|
||||||
);
|
tab_id,
|
||||||
|
lbl,
|
||||||
|
total
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"{} {} [{}] ({} total)",
|
||||||
|
color::success_indicator(),
|
||||||
|
label_noun,
|
||||||
|
tab_id,
|
||||||
|
total
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -604,7 +618,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
if data.get("closed").is_some() {
|
if data.get("closed").is_some() {
|
||||||
let label = match action {
|
let label = match action {
|
||||||
Some("tab_close") => {
|
Some("tab_close") => {
|
||||||
if let Some(closed_id) = data.get("tabId").and_then(|v| v.as_i64()) {
|
if let Some(closed_id) = data.get("tabId").and_then(|v| v.as_str()) {
|
||||||
println!("{} Tab [{}] closed", color::success_indicator(), closed_id);
|
println!("{} Tab [{}] closed", color::success_indicator(), closed_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1057,7 +1071,6 @@ Aliases: goto, navigate
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
--headers <json> Set HTTP headers (scoped to this origin)
|
--headers <json> Set HTTP headers (scoped to this origin)
|
||||||
--headed Show browser window
|
--headed Show browser window
|
||||||
|
|
||||||
@@ -1081,7 +1094,6 @@ the browser's back button.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser back
|
agent-browser back
|
||||||
@@ -1099,7 +1111,6 @@ the browser's forward button.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser forward
|
agent-browser forward
|
||||||
@@ -1117,7 +1128,6 @@ the browser's reload button.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser reload
|
agent-browser reload
|
||||||
@@ -1141,7 +1151,6 @@ Options:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser click "#submit-button"
|
agent-browser click "#submit-button"
|
||||||
@@ -1163,7 +1172,6 @@ or triggering double-click handlers.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser dblclick "#editable-text"
|
agent-browser dblclick "#editable-text"
|
||||||
@@ -1182,7 +1190,6 @@ This replaces any existing content in the field.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser fill "#email" "user@example.com"
|
agent-browser fill "#email" "user@example.com"
|
||||||
@@ -1202,7 +1209,6 @@ Unlike fill, this does not clear existing content first.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser type "#search" "hello"
|
agent-browser type "#search" "hello"
|
||||||
@@ -1226,7 +1232,6 @@ triggering hover states or dropdown menus.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser hover "#dropdown-trigger"
|
agent-browser hover "#dropdown-trigger"
|
||||||
@@ -1244,7 +1249,6 @@ Sets keyboard focus to the specified element.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser focus "#input-field"
|
agent-browser focus "#input-field"
|
||||||
@@ -1262,7 +1266,6 @@ Checks a checkbox element. If already checked, no action is taken.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser check "#terms-checkbox"
|
agent-browser check "#terms-checkbox"
|
||||||
@@ -1280,7 +1283,6 @@ Unchecks a checkbox element. If already unchecked, no action is taken.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser uncheck "#newsletter-opt-in"
|
agent-browser uncheck "#newsletter-opt-in"
|
||||||
@@ -1298,7 +1300,6 @@ Selects one or more options in a <select> dropdown by value.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser select "#country" "US"
|
agent-browser select "#country" "US"
|
||||||
@@ -1317,7 +1318,6 @@ Drags an element from source to target location.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser drag "#draggable" "#drop-zone"
|
agent-browser drag "#draggable" "#drop-zone"
|
||||||
@@ -1335,7 +1335,6 @@ Uploads one or more files to a file input element.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser upload "#file-input" ./document.pdf
|
agent-browser upload "#file-input" ./document.pdf
|
||||||
@@ -1357,7 +1356,6 @@ Arguments:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser download "#download-btn" ./file.pdf
|
agent-browser download "#download-btn" ./file.pdf
|
||||||
@@ -1389,7 +1387,6 @@ Modifiers (combine with +):
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser press Enter
|
agent-browser press Enter
|
||||||
@@ -1411,7 +1408,6 @@ Useful for holding modifier keys.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser keydown Shift
|
agent-browser keydown Shift
|
||||||
@@ -1429,7 +1425,6 @@ Releases a key that was pressed with keydown.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser keyup Shift
|
agent-browser keyup Shift
|
||||||
@@ -1458,7 +1453,6 @@ directly — it already operates on the current focus.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser keyboard type "Hello, World!"
|
agent-browser keyboard type "Hello, World!"
|
||||||
@@ -1493,7 +1487,6 @@ Options:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser scroll
|
agent-browser scroll
|
||||||
@@ -1516,7 +1509,6 @@ Aliases: scrollinto
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser scrollintoview "#footer"
|
agent-browser scrollintoview "#footer"
|
||||||
@@ -1554,7 +1546,6 @@ Wait for text to disappear:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser wait "#loading-spinner"
|
agent-browser wait "#loading-spinner"
|
||||||
@@ -1596,7 +1587,6 @@ Options:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser screenshot
|
agent-browser screenshot
|
||||||
@@ -1620,7 +1610,6 @@ Saves the current page as a PDF file.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser pdf ./page.pdf
|
agent-browser pdf ./page.pdf
|
||||||
@@ -1649,7 +1638,6 @@ Options:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser snapshot
|
agent-browser snapshot
|
||||||
@@ -1676,7 +1664,6 @@ Options:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser eval "document.title"
|
agent-browser eval "document.title"
|
||||||
@@ -1709,7 +1696,6 @@ Options:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser close
|
agent-browser close
|
||||||
@@ -1761,7 +1747,6 @@ Subcommands:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser get text @e1
|
agent-browser get text @e1
|
||||||
@@ -1794,7 +1779,6 @@ Subcommands:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser is visible "#modal"
|
agent-browser is visible "#modal"
|
||||||
@@ -1834,7 +1818,6 @@ Options:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser find role button click --name Submit
|
agent-browser find role button click --name Submit
|
||||||
@@ -1865,7 +1848,6 @@ Subcommands:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser mouse move 100 200
|
agent-browser mouse move 100 200
|
||||||
@@ -1899,7 +1881,6 @@ Settings:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser set viewport 1920 1080
|
agent-browser set viewport 1920 1080
|
||||||
@@ -1940,7 +1921,6 @@ Subcommands:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser network route "**/api/*" --abort
|
agent-browser network route "**/api/*" --abort
|
||||||
@@ -1978,7 +1958,6 @@ Operations:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser storage local
|
agent-browser storage local
|
||||||
@@ -2018,7 +1997,6 @@ for the current page URL.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
# Simple cookie for current page
|
# Simple cookie for current page
|
||||||
@@ -2051,27 +2029,34 @@ agent-browser tab - Manage browser tabs
|
|||||||
|
|
||||||
Usage: agent-browser tab [operation] [args]
|
Usage: agent-browser tab [operation] [args]
|
||||||
|
|
||||||
Manage browser tabs in the current window.
|
Manage browser tabs in the current window. Stable tab ids look like `t1`,
|
||||||
|
`t2`, `t3`. An id is never reused within a session, so scripts can keep
|
||||||
|
referring to the same tab across commands. Optional user-assigned labels
|
||||||
|
(e.g. `docs`, `app`) are interchangeable with ids everywhere a tab ref is
|
||||||
|
accepted.
|
||||||
|
|
||||||
Operations:
|
Operations:
|
||||||
list List all tabs with tab IDs (default)
|
list List open tabs with their ids and labels (default)
|
||||||
new [url] Open new tab
|
new [url] Open a new tab
|
||||||
close [id] Close tab by ID (current if no ID)
|
new --label <name> [url] Open a new tab with a label like `docs` or `app`
|
||||||
<id> Switch to tab by ID
|
close [t<N>|label] Close a tab (current if no ref given)
|
||||||
|
<t<N>|label> Switch to a tab by id or label
|
||||||
|
|
||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser tab
|
agent-browser tab
|
||||||
agent-browser tab list
|
agent-browser tab list
|
||||||
agent-browser tab new
|
agent-browser tab new
|
||||||
agent-browser tab new https://example.com
|
agent-browser tab new https://example.com
|
||||||
agent-browser tab 2
|
agent-browser tab new --label docs https://docs.example.com
|
||||||
|
agent-browser tab t2
|
||||||
|
agent-browser tab docs
|
||||||
agent-browser tab close
|
agent-browser tab close
|
||||||
agent-browser tab close 1
|
agent-browser tab close t1
|
||||||
|
agent-browser tab close docs
|
||||||
"##
|
"##
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2090,7 +2075,6 @@ Operations:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser window new
|
agent-browser window new
|
||||||
@@ -2113,7 +2097,6 @@ Arguments:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser frame "#embed-iframe"
|
agent-browser frame "#embed-iframe"
|
||||||
@@ -2201,7 +2184,6 @@ Operations:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser dialog accept
|
agent-browser dialog accept
|
||||||
@@ -2227,7 +2209,6 @@ Operations:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser trace start
|
agent-browser trace start
|
||||||
@@ -2259,7 +2240,6 @@ Start Options:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
# Basic profiling
|
# Basic profiling
|
||||||
@@ -2299,7 +2279,6 @@ Operations:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
# Record from current page (preserves login state)
|
# Record from current page (preserves login state)
|
||||||
@@ -2332,7 +2311,6 @@ Options:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser console
|
agent-browser console
|
||||||
@@ -2353,7 +2331,6 @@ Options:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser errors
|
agent-browser errors
|
||||||
@@ -2373,7 +2350,6 @@ Visually highlights an element on the page for debugging.
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser highlight "#target-element"
|
agent-browser highlight "#target-element"
|
||||||
@@ -2399,7 +2375,6 @@ Operations:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser clipboard read
|
agent-browser clipboard read
|
||||||
@@ -2439,7 +2414,6 @@ State Encryption:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser state save ./auth-state.json
|
agent-browser state save ./auth-state.json
|
||||||
@@ -2472,7 +2446,6 @@ Environment:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser session
|
agent-browser session
|
||||||
@@ -2570,7 +2543,6 @@ Supported URL formats:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
# Connect to local Chrome with remote debugging
|
# Connect to local Chrome with remote debugging
|
||||||
@@ -2732,7 +2704,6 @@ URL Diff:
|
|||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
--tab <id> Target specific tab ID
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
agent-browser diff snapshot
|
agent-browser diff snapshot
|
||||||
@@ -3027,7 +2998,6 @@ Authentication:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
|
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
|
||||||
--tab <id> Target specific tab ID for the command
|
|
||||||
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
|
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
|
||||||
--extension <path> Load browser extensions (repeatable)
|
--extension <path> Load browser extensions (repeatable)
|
||||||
--args <args> Browser launch args, comma or newline separated (or AGENT_BROWSER_ARGS)
|
--args <args> Browser launch args, comma or newline separated (or AGENT_BROWSER_ARGS)
|
||||||
|
|||||||
@@ -188,27 +188,48 @@ agent-browser network har stop [output.har] # Stop and save HAR (temp path if
|
|||||||
## Tabs & frames
|
## Tabs & frames
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-browser tab # List tabs (each row includes a stable tabId)
|
agent-browser tab # List tabs (each row shows tabId and label)
|
||||||
agent-browser tab new [url] # New tab
|
agent-browser tab new [url] # New tab
|
||||||
agent-browser tab <id> # Switch to tab by tabId
|
agent-browser tab new --label docs [url] # New tab with a user-assigned label
|
||||||
agent-browser tab close [id] # Close tab by tabId (defaults to active)
|
agent-browser tab <t<N>|label> # Switch to a tab by id or label
|
||||||
agent-browser window new # Open new browser window
|
agent-browser tab close [t<N>|label] # Close a tab (defaults to active)
|
||||||
agent-browser frame <sel> # Switch to iframe by CSS selector
|
agent-browser window new # Open new browser window
|
||||||
agent-browser frame @e3 # Switch to iframe by element ref
|
agent-browser frame <sel> # Switch to iframe by CSS selector
|
||||||
agent-browser frame main # Back to main frame
|
agent-browser frame @e3 # Switch to iframe by element ref
|
||||||
|
agent-browser frame main # Back to main frame
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stable tab IDs and `--tab`
|
### Stable tab ids and labels
|
||||||
|
|
||||||
Tab IDs are assigned on creation and never reused within a session, so a given
|
Tab ids are stable strings of the form `t1`, `t2`, `t3`. They're never reused
|
||||||
`tabId` keeps pointing at the same tab even when other tabs are opened or
|
within a session, so `t2` keeps pointing at the same tab even as other tabs
|
||||||
closed. The global `--tab <id>` flag runs a single command against a specific
|
are opened or closed. The `t` prefix mirrors the `@e1` element-ref convention
|
||||||
tab without changing the active tab:
|
and is not interchangeable with positional integers — `agent-browser tab 2`
|
||||||
|
errors with a teaching message; use `t2`.
|
||||||
|
|
||||||
|
You can also assign a memorable label (`docs`, `app`, `admin`) at tab-creation
|
||||||
|
time and use it anywhere an id is accepted:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-browser tab new https://docs.example.com # opens and activates tab 2
|
agent-browser tab new --label docs https://docs.example.com
|
||||||
agent-browser --tab 1 snapshot # peek at tab 1; tab 2 stays active
|
agent-browser tab docs # switch to the docs tab
|
||||||
agent-browser click "#submit" # runs on tab 2 as expected
|
agent-browser snapshot # populate refs for docs
|
||||||
|
agent-browser click @e3 # click uses docs's refs
|
||||||
|
agent-browser tab close docs # close by label
|
||||||
|
```
|
||||||
|
|
||||||
|
Labels are never auto-generated and never rewritten on navigation — an agent
|
||||||
|
that names a tab `docs` keeps that name until the tab is closed. Labels are
|
||||||
|
unique within a session; creating a second tab with an existing label
|
||||||
|
errors.
|
||||||
|
|
||||||
|
Refs (`@e1`, etc.) are scoped to the tab that was active when the snapshot
|
||||||
|
ran, so switch tabs first, then snapshot and interact:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser tab docs # switch first
|
||||||
|
agent-browser snapshot # refs for docs
|
||||||
|
agent-browser click @e3 # uses docs's refs
|
||||||
```
|
```
|
||||||
|
|
||||||
### Iframe support
|
### Iframe support
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ Every CLI flag can be set in the config file using its camelCase equivalent:
|
|||||||
<tr><td><code>debug</code></td><td><code>--debug</code></td><td>boolean</td></tr>
|
<tr><td><code>debug</code></td><td><code>--debug</code></td><td>boolean</td></tr>
|
||||||
<tr><td><code>session</code></td><td><code>--session</code></td><td>string</td></tr>
|
<tr><td><code>session</code></td><td><code>--session</code></td><td>string</td></tr>
|
||||||
<tr><td><code>sessionName</code></td><td><code>--session-name</code></td><td>string</td></tr>
|
<tr><td><code>sessionName</code></td><td><code>--session-name</code></td><td>string</td></tr>
|
||||||
<tr><td><code>tab</code></td><td><code>--tab</code></td><td>number</td></tr>
|
|
||||||
<tr><td><code>executablePath</code></td><td><code>--executable-path</code></td><td>string</td></tr>
|
<tr><td><code>executablePath</code></td><td><code>--executable-path</code></td><td>string</td></tr>
|
||||||
<tr><td><code>extensions</code></td><td><code>--extension</code></td><td>string[]</td></tr>
|
<tr><td><code>extensions</code></td><td><code>--extension</code></td><td>string[]</td></tr>
|
||||||
<tr><td><code>profile</code></td><td><code>--profile</code></td><td>string</td></tr>
|
<tr><td><code>profile</code></td><td><code>--profile</code></td><td>string</td></tr>
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ function TabNode({ tab, isViewed, isSessionActive, onClose, onSwitch, onSelectSe
|
|||||||
>
|
>
|
||||||
<TabFavicon url={tab.url} />
|
<TabFavicon url={tab.url} />
|
||||||
<span className="min-w-0 flex-1 truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{tab.title || tab.url || `Tab ${tab.index}`}
|
{tab.title || tab.url || `Tab ${tab.label ?? tab.tabId}`}
|
||||||
</span>
|
</span>
|
||||||
{tab.active && (
|
{tab.active && (
|
||||||
<span className="shrink-0 rounded border border-border px-1 py-px text-[9px] leading-none text-muted-foreground">
|
<span className="shrink-0 rounded border border-border px-1 py-px text-[9px] leading-none text-muted-foreground">
|
||||||
@@ -199,9 +199,9 @@ function SessionNode({
|
|||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onCloseTab: (tabIndex: number) => void;
|
onCloseTab: (tabRef: string) => void;
|
||||||
onAddTab: () => void;
|
onAddTab: () => void;
|
||||||
onSwitchTab: (tabIndex: number) => void;
|
onSwitchTab: (tabRef: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onKill: () => void;
|
onKill: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -319,9 +319,20 @@ function SessionNode({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="overflow-hidden pb-1">
|
<div className="overflow-hidden pb-1">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => {
|
||||||
<TabNode key={tab.index} tab={tab} isViewed={isActive && tab.active} isSessionActive={isActive} onClose={() => onCloseTab(tab.index)} onSwitch={() => onSwitchTab(tab.index)} onSelectSession={onSelect} />
|
const tabRef = tab.label ?? tab.tabId;
|
||||||
))}
|
return (
|
||||||
|
<TabNode
|
||||||
|
key={tab.tabId}
|
||||||
|
tab={tab}
|
||||||
|
isViewed={isActive && tab.active}
|
||||||
|
isSessionActive={isActive}
|
||||||
|
onClose={() => onCloseTab(tabRef)}
|
||||||
|
onSwitch={() => onSwitchTab(tabRef)}
|
||||||
|
onSelectSession={onSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<button
|
<button
|
||||||
onClick={onAddTab}
|
onClick={onAddTab}
|
||||||
className="flex w-full items-center gap-2 py-1 pr-1 pl-7 text-xs text-muted-foreground hover:text-foreground"
|
className="flex w-full items-center gap-2 py-1 pr-1 pl-7 text-xs text-muted-foreground hover:text-foreground"
|
||||||
@@ -447,9 +458,9 @@ export function SessionTree() {
|
|||||||
expanded={isExpanded(s.port)}
|
expanded={isExpanded(s.port)}
|
||||||
onSelect={() => setActivePort(s.port)}
|
onSelect={() => setActivePort(s.port)}
|
||||||
onToggle={() => toggleExpanded(s.port)}
|
onToggle={() => toggleExpanded(s.port)}
|
||||||
onCloseTab={(tabIndex) => dispatchCloseTab({ port: s.port, tabIndex })}
|
onCloseTab={(tabRef) => dispatchCloseTab({ port: s.port, tabRef })}
|
||||||
onAddTab={() => dispatchAddTab(s.port)}
|
onAddTab={() => dispatchAddTab(s.port)}
|
||||||
onSwitchTab={(tabIndex) => dispatchSwitchTab({ port: s.port, tabIndex })}
|
onSwitchTab={(tabRef) => dispatchSwitchTab({ port: s.port, tabRef })}
|
||||||
onClose={() => dispatchCloseSession(s.port)}
|
onClose={() => dispatchCloseSession(s.port)}
|
||||||
onKill={() => dispatchKillSession(s.port)}
|
onKill={() => dispatchKillSession(s.port)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -160,10 +160,10 @@ export const closeAllSessionsAtom = atom(null, (get, set) => {
|
|||||||
|
|
||||||
export const closeTabAtom = atom(
|
export const closeTabAtom = atom(
|
||||||
null,
|
null,
|
||||||
(get, _set, { port, tabIndex }: { port: number; tabIndex: number }) => {
|
(get, _set, { port, tabRef }: { port: number; tabRef: string }) => {
|
||||||
const sessions = get(sessionsAtom);
|
const sessions = get(sessionsAtom);
|
||||||
const s = sessions.find((x) => x.port === port)?.session;
|
const s = sessions.find((x) => x.port === port)?.session;
|
||||||
if (s) execCommand(sessionArgs(s, "tab", "close", String(tabIndex)));
|
if (s) execCommand(sessionArgs(s, "tab", "close", tabRef));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -175,10 +175,10 @@ export const addTabAtom = atom(null, (get, _set, port: number) => {
|
|||||||
|
|
||||||
export const switchTabAtom = atom(
|
export const switchTabAtom = atom(
|
||||||
null,
|
null,
|
||||||
(get, _set, { port, tabIndex }: { port: number; tabIndex: number }) => {
|
(get, _set, { port, tabRef }: { port: number; tabRef: string }) => {
|
||||||
const sessions = get(sessionsAtom);
|
const sessions = get(sessionsAtom);
|
||||||
const s = sessions.find((x) => x.port === port)?.session;
|
const s = sessions.find((x) => x.port === port)?.session;
|
||||||
if (s) execCommand(sessionArgs(s, "tab", String(tabIndex)));
|
if (s) execCommand(sessionArgs(s, "tab", tabRef));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ export interface ErrorMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TabInfo {
|
export interface TabInfo {
|
||||||
index: number;
|
/** Stable tab id like `t1`, `t2`. Never reused within a session. */
|
||||||
|
tabId: string;
|
||||||
|
/** Optional user-assigned label (e.g. `docs`). Interchangeable with `tabId`. */
|
||||||
|
label?: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
@@ -166,24 +166,41 @@ agent-browser network requests --filter api # Filter requests
|
|||||||
## Tabs and Windows
|
## Tabs and Windows
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-browser tab # List tabs (each row includes a stable tabId)
|
agent-browser tab # List tabs with tabId and label
|
||||||
agent-browser tab new [url] # New tab
|
agent-browser tab new [url] # New tab
|
||||||
agent-browser tab 2 # Switch to tab by tabId
|
agent-browser tab new --label docs [url] # New tab with a memorable label
|
||||||
agent-browser tab close # Close current tab
|
agent-browser tab t2 # Switch to tab by id
|
||||||
agent-browser tab close 2 # Close tab by tabId
|
agent-browser tab docs # Switch to tab by label
|
||||||
agent-browser window new # New window
|
agent-browser tab close # Close current tab
|
||||||
|
agent-browser tab close t2 # Close tab by id
|
||||||
|
agent-browser tab close docs # Close tab by label
|
||||||
|
agent-browser window new # New window
|
||||||
```
|
```
|
||||||
|
|
||||||
Tab IDs are stable and never reused within a session, so the same `tabId` keeps
|
Tab ids are stable strings of the form `t1`, `t2`, `t3`. They're never reused
|
||||||
referring to the same tab even when other tabs are opened or closed. For a
|
within a session, so the same id keeps referring to the same tab across
|
||||||
non-intrusive peek at another tab, use the global `--tab <id>` flag; the
|
commands. Positional integers are **not** accepted — `tab 2` errors with a
|
||||||
active tab is restored after the command:
|
teaching message; use `t2`.
|
||||||
|
|
||||||
|
User-assigned labels (`docs`, `app`, `admin`) are interchangeable with ids
|
||||||
|
everywhere a tab ref is accepted. Labels are the agent-friendly way to write
|
||||||
|
multi-tab workflows:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-browser --tab 1 snapshot # snapshot tab 1 without switching
|
agent-browser tab new --label docs https://docs.example.com
|
||||||
agent-browser --tab 3 click @e1 # click @e1 on tab 3 and return
|
agent-browser tab new --label app https://app.example.com
|
||||||
|
agent-browser tab docs # switch to docs
|
||||||
|
agent-browser snapshot # populate refs for docs
|
||||||
|
agent-browser click @e1 # ref click on docs
|
||||||
|
agent-browser tab app # switch to app
|
||||||
|
agent-browser tab close docs # close by label
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Labels are never auto-generated, never rewritten on navigation, and must be
|
||||||
|
unique within a session. To interact with another tab, switch to it first:
|
||||||
|
the daemon maintains a single active tab, so refs (`@eN`) belong to the tab
|
||||||
|
that was active when the snapshot ran.
|
||||||
|
|
||||||
## Frames
|
## Frames
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Reference in New Issue
Block a user