From 26a2f7280f6bd2a3664afa449619757a3d6a2389 Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 12 Nov 2026 01:22:23 +0900 Subject: [PATCH 1/5] fix(browser): correct the get count and captcha solve help --- docs/cli-reference.md | 16 ++++++++-------- docs/references/steel-browser-commands.md | 2 +- docs/references/steel-browser.md | 4 +++- src/commands/browser/action.rs | 10 ++++++++-- src/commands/browser/captcha.rs | 8 ++++---- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 3fa2da6..ac9fb91 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -297,12 +297,12 @@ steel browser captcha ### Subcommands -- `solve`: Solve a CAPTCHA on the current page +- `solve`: Solve every detected CAPTCHA, or the one a flag picks - `status`: Check CAPTCHA status ## steel browser captcha solve -Solve a CAPTCHA on the current page +Solve every detected CAPTCHA, or the one a flag picks ### Usage @@ -312,9 +312,9 @@ steel browser captcha solve ### Parameters -- `--page-id` (string, optional): Page ID -- `--url` (string, optional): Page URL for targeted CAPTCHA solving -- `--task-id` (string, optional): CAPTCHA task ID for targeted solving +- `--page-id` (string, optional): Solve only the CAPTCHA on this page +- `--url` (string, optional): Solve only the CAPTCHA on the page at this URL +- `--task-id` (string, optional): Solve only this CAPTCHA task ## steel browser captcha status @@ -774,7 +774,7 @@ steel browser get - `attr`: Get an element attribute - `url`: Get the page URL - `title`: Get the page title -- `count`: Count matching elements +- `count`: Count elements that match a CSS selector - `box`: Get an element's bounding box ## steel browser get text @@ -870,7 +870,7 @@ steel browser get title ## steel browser get count -Count matching elements +Count elements that match a CSS selector ### Usage @@ -880,7 +880,7 @@ steel browser get count ### Parameters -- `selector` (string, required): Element ref (e.g. e5) or CSS selector +- `selector` (string, required): CSS selector (refs do not work here) ## steel browser get box diff --git a/docs/references/steel-browser-commands.md b/docs/references/steel-browser-commands.md index 7412a57..0797555 100644 --- a/docs/references/steel-browser-commands.md +++ b/docs/references/steel-browser-commands.md @@ -77,7 +77,7 @@ steel browser get value steel browser get attr steel browser get url steel browser get title -steel browser get count +steel browser get count # CSS selector only; a ref names one element steel browser get box steel browser is visible|enabled|checked steel browser find # CSS by default; --by role|text|label|placeholder|alt|title|testid, --name, --exact diff --git a/docs/references/steel-browser.md b/docs/references/steel-browser.md index 51b0c23..29f9e19 100644 --- a/docs/references/steel-browser.md +++ b/docs/references/steel-browser.md @@ -129,7 +129,9 @@ Main flags: ### `steel browser captcha solve` -Purpose: manually trigger CAPTCHA solving for a target session. +Purpose: manually trigger CAPTCHA solving for a target session. Without flags it +solves every CAPTCHA detected in the session. `--page-id`, `--url`, or +`--task-id` solves only the one it names. Main flags: diff --git a/src/commands/browser/action.rs b/src/commands/browser/action.rs index 68537a5..375ca16 100644 --- a/src/commands/browser/action.rs +++ b/src/commands/browser/action.rs @@ -26,6 +26,12 @@ pub struct OptionalSelectorArg { pub selector: Option, } +#[derive(Parser)] +pub struct CssSelectorArg { + /// CSS selector (refs do not work here) + pub selector: String, +} + #[derive(Parser)] pub struct RemovedArgs { #[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)] @@ -207,8 +213,8 @@ pub enum GetCommand { Url, /// Get the page title Title, - /// Count matching elements - Count(SelectorArg), + /// Count elements that match a CSS selector + Count(CssSelectorArg), /// Get an element's bounding box #[command(name = "box")] Box(SelectorArg), diff --git a/src/commands/browser/captcha.rs b/src/commands/browser/captcha.rs index a966399..456894d 100644 --- a/src/commands/browser/captcha.rs +++ b/src/commands/browser/captcha.rs @@ -7,7 +7,7 @@ use crate::util::output; #[derive(Subcommand)] pub enum Command { - /// Solve a CAPTCHA on the current page + /// Solve every detected CAPTCHA, or the one a flag picks Solve(SolveArgs), /// Check CAPTCHA status @@ -25,15 +25,15 @@ impl Command { #[derive(Parser)] pub struct SolveArgs { - /// Page ID + /// Solve only the CAPTCHA on this page #[arg(long)] pub page_id: Option, - /// Page URL for targeted CAPTCHA solving + /// Solve only the CAPTCHA on the page at this URL #[arg(long)] pub url: Option, - /// CAPTCHA task ID for targeted solving + /// Solve only this CAPTCHA task #[arg(long)] pub task_id: Option, } From c542d71c2e4747ddb9d4c66c1c0caf7d721f04b4 Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 12 Nov 2026 01:25:15 +0900 Subject: [PATCH 2/5] fix(browser): stop pointing at the removed dialog command --- docs/cli-reference.md | 18 --- docs/references/agent-troubleshooting.md | 3 +- docs/references/steel-browser-commands.md | 5 +- docs/references/steel-browser.md | 3 +- src/browser/state.rs | 15 ++- src/commands/browser/action.rs | 151 ++++++++++------------ src/commands/browser/batch.rs | 14 +- 7 files changed, 102 insertions(+), 107 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ac9fb91..7674227 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -64,7 +64,6 @@ Steel CLI - browser automation for AI agents. This file is generated from `steel - [steel browser tab new](#steel-browser-tab-new) - [steel browser tab switch](#steel-browser-tab-switch) - [steel browser tab close](#steel-browser-tab-close) -- [steel browser dialog](#steel-browser-dialog) - [steel browser status](#steel-browser-status) - [steel browser cancel](#steel-browser-cancel) - [steel sessions](#steel-sessions) @@ -222,7 +221,6 @@ steel browser - `is`: Check element state - `wait`: Wait for an element, text, URL, load state, download, or a fixed time - `tab`: List, open, switch, or close tabs -- `dialog`: Accept or dismiss an open dialog - `status`: Wait for a command that is still running - `cancel`: Stop a command that is still running @@ -1045,22 +1043,6 @@ steel browser tab close - `tab` (string, optional): Tab id from `tab list`, such as t2 -## steel browser dialog - -Accept or dismiss an open dialog - -### Usage - -```bash -steel browser dialog -``` - -### Parameters - -- `action` (enum, required): accept or dismiss -- `id` (string, optional): Dialog id such as d-2 (default: the last dialog the CLI saw) -- `--text` (string, optional): Answer for a prompt dialog - ## steel browser status Wait for a command that is still running diff --git a/docs/references/agent-troubleshooting.md b/docs/references/agent-troubleshooting.md index 083a288..5674a53 100644 --- a/docs/references/agent-troubleshooting.md +++ b/docs/references/agent-troubleshooting.md @@ -88,7 +88,8 @@ steel browser start --local --session "$SESSION_NAME" - `stale_ref`: take a new snapshot and use the new refs. - `tab_required`: several tabs are open. Run `steel browser tab list`, then `steel browser tab switch `. -- `dialog_pending`: answer with `steel browser dialog accept` or `dismiss`. +- `dialog_pending`: commands cannot answer dialogs. Close it in the live view + (`steel browser live`), or run `steel browser tab close `. - `delivery_failed`: the API could not reach the browser. Run the same command again; it will not run twice. - Stale state: diff --git a/docs/references/steel-browser-commands.md b/docs/references/steel-browser-commands.md index 0797555..554c997 100644 --- a/docs/references/steel-browser-commands.md +++ b/docs/references/steel-browser-commands.md @@ -114,12 +114,11 @@ Commands without a ref run on the current tab. When a page opens a tab, the CLI ## Dialogs and long commands ```bash -steel browser dialog accept [d-2] # or dismiss; --text answers a prompt steel browser status x-7 # wait for a command that is still running steel browser cancel x-7 # stop it ``` -Dialogs stay open until you answer them. A command that opens one prints the dialog id. +Commands cannot answer dialogs. A dialog stays open until it is closed, and commands on its tab fail with `dialog_pending` until then. The CLI prints the dialog id and its tab. Close the dialog in the live view (`steel browser live` prints its URL), or close its tab with `steel browser tab close `. The last open tab cannot be closed, so open another one first with `steel browser tab new`. `steel browser dialog` was removed and prints this advice. ## Batch @@ -127,7 +126,7 @@ Dialogs stay open until you answer them. A command that opens one prints the dia steel browser batch "fill e3 Seoul" "fill e4 Tokyo" "click e5" "snapshot -i" ``` -Batch sends all commands in one request (at most 50). They run in order and stop at the first failure. Each command is one string. Group words with double or single quotes (`"fill e3 'New York'"`). A quote opens only at the start of a word or right after `=`, and closes only at the end of a word. Quotes anywhere else stay as typed, so `"eval document.querySelector('h1').textContent"` works as written. Inside double quotes, `\"` and `\\` are escapes. An unclosed quote is an error. `tab new`, `tab switch`, `tab close`, `dialog`, `status`, and `cancel` must run on their own. `stop` is not a batch command. +Batch sends all commands in one request (at most 50). They run in order and stop at the first failure. Each command is one string. Group words with double or single quotes (`"fill e3 'New York'"`). A quote opens only at the start of a word or right after `=`, and closes only at the end of a word. Quotes anywhere else stay as typed, so `"eval document.querySelector('h1').textContent"` works as written. Inside double quotes, `\"` and `\\` are escapes. An unclosed quote is an error. `tab new`, `tab switch`, `tab close`, `status`, and `cancel` must run on their own. `stop` is not a batch command. ## Retries and output diff --git a/docs/references/steel-browser.md b/docs/references/steel-browser.md index 29f9e19..90a4c49 100644 --- a/docs/references/steel-browser.md +++ b/docs/references/steel-browser.md @@ -187,7 +187,8 @@ Self-hosted steel-browser serves the commands API only when it runs with ensure all steps share the same `--session `. - `stale_ref`: take a new snapshot (`steel browser snapshot -i`) and use the new refs. - `tab_required`: several tabs are open. Pick one with `steel browser tab switch `. -- `dialog_pending`: answer the dialog with `steel browser dialog accept` or `dismiss`. +- `dialog_pending`: a dialog blocks the tab, and commands cannot answer it. Close + it in the live view (`steel browser live`), or run `steel browser tab close `. - `This is the stored result of an earlier run`: the same command already ran after the last response the CLI received. Nothing ran again. - `delivery_failed`: the API could not reach the browser. Run the same command diff --git a/src/browser/state.rs b/src/browser/state.rs index e95fa67..096434d 100644 --- a/src/browser/state.rs +++ b/src/browser/state.rs @@ -43,8 +43,6 @@ pub struct BrowserSession { #[serde(default, skip_serializing_if = "Option::is_none")] pub events: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub dialog: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub snapshot: Option, } @@ -64,7 +62,6 @@ impl BrowserSession { next: None, tab: None, events: None, - dialog: None, snapshot: None, } } @@ -245,6 +242,18 @@ mod tests { assert!(!loaded.attached); } + #[test] + fn state_files_with_a_saved_dialog_still_load() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("x.json"), + r#"{"name":"x","id":"abc","mode":"cloud","apiUrl":"https://api.steel.dev/v1","tab":"t1","dialog":"d-2"}"#, + ) + .unwrap(); + let loaded = load_in("x", dir.path()).unwrap().unwrap(); + assert_eq!(loaded.tab.as_deref(), Some("t1")); + } + #[test] fn attached_flag_roundtrips_and_is_omitted_when_false() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/commands/browser/action.rs b/src/commands/browser/action.rs index 375ca16..64481a9 100644 --- a/src/commands/browser/action.rs +++ b/src/commands/browser/action.rs @@ -122,8 +122,6 @@ pub enum ActionCommand { #[command(subcommand)] command: TabCommand, }, - /// Accept or dismiss an open dialog - Dialog(DialogArgs), /// Wait for a command that is still running Status(ExecutionArgs), /// Stop a command that is still running @@ -145,6 +143,8 @@ pub enum ActionCommand { ScrollIntoView(RemovedArgs), #[command(name = "setvalue", hide = true)] SetValue(RemovedArgs), + #[command(hide = true)] + Dialog(RemovedArgs), } impl ActionCommand { @@ -497,18 +497,6 @@ pub struct TabCloseArgs { pub tab: Option, } -#[derive(Parser)] -pub struct DialogArgs { - /// accept or dismiss - #[arg(value_parser = ["accept", "dismiss"])] - pub action: String, - /// Dialog id such as d-2 (default: the last dialog the CLI saw) - pub id: Option, - /// Answer for a prompt dialog - #[arg(long)] - pub text: Option, -} - #[derive(Parser)] pub struct ExecutionArgs { /// Execution id such as x-7 @@ -534,8 +522,6 @@ pub enum Output { Switch(String), Saved(PathBuf), Download(Option), - Execution, - Dialog, } pub struct Step { @@ -564,7 +550,7 @@ impl Step { pub enum Plan { Run(Step), - Control(Value, Output), + Control(Value), } struct Cmd(Map); @@ -860,21 +846,11 @@ impl ActionCommand { single(cmd("tab").set("action", "close").opt("tab", a.tab), O::None) } }, - Self::Dialog(a) => Ok(Plan::Control( - cmd("dialog") - .opt("dialog", a.id) - .set("action", a.action) - .opt("text", a.text) - .done(), - O::Dialog, - )), Self::Status(a) => Ok(Plan::Control( cmd("status").set("execution", a.execution).done(), - O::Execution, )), Self::Cancel(a) => Ok(Plan::Control( cmd("cancel").set("execution", a.execution).done(), - O::Execution, )), Self::Cookies(_) => Err(removed( "cookies", @@ -908,6 +884,9 @@ impl ActionCommand { "setvalue", "Use `steel browser fill `.", )), + Self::Dialog(_) => Err(anyhow!( + "`steel browser dialog` was removed because commands can no longer answer dialogs. Close the dialog in the live view (`steel browser live`), or run `steel browser tab close `." + )), } } } @@ -981,7 +960,7 @@ fn scoped(mut command: Value, tab: Option<&str>) -> Value { return command; }; let eligible = match map.get("command").and_then(Value::as_str) { - Some("status" | "cancel" | "dialog") | None => false, + Some("status" | "cancel") | None => false, Some("tab") => map.get("action").and_then(Value::as_str) == Some("close"), Some(_) => true, }; @@ -1026,7 +1005,9 @@ fn hint(code: &str, message: &str) -> &'static str { "stale_ref" => " Run `steel browser snapshot -i`.", "tab_required" => " Run `steel browser tab switch `.", "tab_not_found" => " Run `steel browser tab list`.", - "dialog_pending" => " Run `steel browser dialog accept` or `steel browser dialog dismiss`.", + "dialog_pending" => { + " Run `steel browser live` to open the live view, or `steel browser tab close `." + } "browser_restarted" => " Run `steel browser tab list`, then `steel browser snapshot -i`.", "missing_capability" => " Run `steel browser start`.", "invalid_operation_token" => " If it did not run, run it again.", @@ -1081,7 +1062,7 @@ fn notices( } if body["epochChanged"] == true { notes.push( - "The page changed since your last command, by another client or a dialog answer. Take a new snapshot before using old refs." + "The page changed since your last command, by another client. Take a new snapshot before using old refs." .to_string(), ); } @@ -1100,11 +1081,11 @@ fn notices( ); } if let Some(dialog) = body.get("dialog").filter(|d| d.is_object()) { + let tab = dialog["tab"].as_str().unwrap_or(""); notes.push(format!( - "Dialog {} ({}) is open on {}: {}. Answer it with `steel browser dialog accept` or `steel browser dialog dismiss`.", + "Dialog {} ({}) is open on {tab}: {}. Commands cannot answer it. Close it in the live view (`steel browser live`), or run `steel browser tab close {tab}`.", dialog["id"].as_str().unwrap_or("?"), dialog["type"].as_str().unwrap_or("dialog"), - dialog["tab"].as_str().unwrap_or("?"), dialog["message"], )); } @@ -1167,9 +1148,6 @@ fn absorb_page(state: &mut BrowserSession, body: &Value) { if let Some(cursor) = body["events"]["cursor"].as_str() { state.events = Some(cursor.to_string()); } - if let Some(dialog) = body["dialog"]["id"].as_str() { - state.dialog = Some(dialog.to_string()); - } } fn restarted(body: &Value) -> bool { @@ -1330,9 +1308,6 @@ impl Browser { if let Some(next) = reply.body.get("next").and_then(Value::as_str) { self.state.next = Some(next.to_string()); } - if code == "dialog_not_pending" { - self.state.dialog = None; - } self.save()?; Err(fail( &code, @@ -1467,7 +1442,7 @@ impl Browser { async fn render(&mut self, output: &Output, result: &mut Value) -> Result> { Ok(match output { - Output::None | Output::Execution | Output::Dialog => None, + Output::None => None, Output::Snapshot => result["snapshot"].as_str().map(str::to_string), Output::Value => show(&result["value"]), Output::Find => Some(find_lines(result)), @@ -1540,24 +1515,11 @@ impl Browser { }) } - pub async fn control(&mut self, mut command: Value, output: Output) -> Result<()> { - if command["command"] == "dialog" && command.get("dialog").is_none() { - let Some(id) = self.state.dialog.clone() else { - bail!( - "No open dialog is known. Pass its id, such as `steel browser dialog accept d-2`." - ); - }; - command["dialog"] = json!(id); - } - let reply = self - .send(self.session().control(command.clone(), None)) - .await?; + pub async fn control(&mut self, command: Value) -> Result<()> { + let reply = self.send(self.session().control(command, None)).await?; let reply = self.check(reply)?; let body = reply.body; absorb_page(&mut self.state, &body); - if matches!(output, Output::Dialog) { - self.state.dialog = None; - } self.save()?; let failed = failure(&body); if output::is_json() { @@ -1569,24 +1531,15 @@ impl Browser { Some((code, message)) => Err(fail(&code, &message, Some(&body))), }; } - if let Some(answered) = body.get("answered").filter(|a| a.is_object()) { - let verb = if answered["accepted"] == true { - "accepted" - } else { - "dismissed" - }; - println!("Dialog {} {verb}.", answered["id"].as_str().unwrap_or("?")); - } else { - let execution = &body["execution"]; - println!( - "{} {} (effect: {})", - execution["id"].as_str().unwrap_or("?"), - execution["state"].as_str().unwrap_or("?"), - execution["effect"].as_str().unwrap_or("?") - ); - for result in body["results"].as_array().into_iter().flatten() { - println!("{result}"); - } + let execution = &body["execution"]; + println!( + "{} {} (effect: {})", + execution["id"].as_str().unwrap_or("?"), + execution["state"].as_str().unwrap_or("?"), + execution["effect"].as_str().unwrap_or("?") + ); + for result in body["results"].as_array().into_iter().flatten() { + println!("{result}"); } match failed { None => Ok(()), @@ -1601,9 +1554,9 @@ pub async fn run(action: ActionCommand, session: Option<&str>, diff: bool) -> Re let mut browser = Browser::open(session, true).await?; browser.execute(vec![step], diff).await } - Plan::Control(command, output) => { + Plan::Control(command) => { let mut browser = Browser::open(session, false).await?; - browser.control(command, output).await + browser.control(command).await } } } @@ -1820,16 +1773,37 @@ mod tests { #[test] fn control_commands() { assert!(matches!( - plan(&["dialog", "accept", "d-2", "--text", "yes"]).unwrap(), - Plan::Control(ref c, Output::Dialog) - if *c == json!({"command": "dialog", "dialog": "d-2", "action": "accept", "text": "yes"}) + plan(&["status", "x-7"]).unwrap(), + Plan::Control(ref c) if *c == json!({"command": "status", "execution": "x-7"}) )); assert!(matches!( - plan(&["status", "x-7"]).unwrap(), - Plan::Control(ref c, Output::Execution) if *c == json!({"command": "status", "execution": "x-7"}) + plan(&["cancel", "x-7"]).unwrap(), + Plan::Control(ref c) if *c == json!({"command": "cancel", "execution": "x-7"}) )); } + #[test] + fn dialog_points_at_the_live_view_and_tab_close() { + for args in [ + vec!["dialog", "accept"], + vec!["dialog", "accept", "d-2", "--text", "yes"], + vec!["dialog", "dismiss", "d-2"], + ] { + let err = plan(&args) + .err() + .unwrap_or_else(|| panic!("{args:?} should fail")); + let message = err.to_string(); + assert!( + message.contains("steel browser live"), + "{args:?}: {message}" + ); + assert!( + message.contains("steel browser tab close"), + "{args:?}: {message}" + ); + } + } + #[test] fn removed_commands_explain_what_to_use() { for args in [ @@ -1970,6 +1944,20 @@ mod tests { assert!(message.contains("effect: applied")); assert!(message.ends_with("Run `steel browser snapshot -i`.")); assert!(failure(&json!({"execution": {"state": "completed"}})).is_none()); + + let blocked = json!({ + "execution": {"id": "x-2", "state": "failed", "effect": "none"}, + "results": [{"ok": false, "effect": "none", "error": { + "code": "dialog_pending", + "message": "Tab t1 shows confirm dialog d-2: \"Sure?\". Close it in the live view, or close its tab." + }}] + }); + let (code, message) = failure(&blocked).unwrap(); + assert_eq!(code, "dialog_pending"); + assert!( + message.ends_with("Run `steel browser live` to open the live view, or `steel browser tab close `."), + "{message}" + ); } #[test] @@ -1985,7 +1973,10 @@ mod tests { }); let notes = notices(&body, true, Some("t1"), false); assert!(notes.iter().any(|n| n.contains("Nothing ran again"))); - assert!(notes.iter().any(|n| n.contains("Dialog d-2 (confirm)"))); + assert!(notes.iter().any(|n| n.contains("Dialog d-2 (confirm)") + && n.contains("steel browser live") + && n.contains("steel browser tab close t1"))); + assert!(!notes.iter().any(|n| n.contains("steel browser dialog"))); assert!(notes.iter().any(|n| n.contains("still loading"))); assert!(notes.iter().any(|n| n.contains("tab switch t3"))); assert_eq!(notes.len(), 4); diff --git a/src/commands/browser/batch.rs b/src/commands/browser/batch.rs index 632822d..391c360 100644 --- a/src/commands/browser/batch.rs +++ b/src/commands/browser/batch.rs @@ -207,8 +207,8 @@ mod tests { "tab switch t2", "tab new", "tab close", - "dialog accept", "status x-1", + "cancel x-1", ] { let err = run( Args { @@ -241,6 +241,18 @@ mod tests { .unwrap_err(); assert!(format!("{err:#}").contains("was removed in 0.5")); + let err = run( + Args { + commands: vec!["dialog accept".to_string()], + bail: false, + }, + Some("unused"), + false, + ) + .await + .unwrap_err(); + assert!(format!("{err:#}").contains("`steel browser dialog` was removed")); + let err = run( Args { commands: vec!["fly away".to_string()], From c2e7256a2e5c7317fe4127a4bd3c88982cb92d3a Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 12 Nov 2026 01:27:28 +0900 Subject: [PATCH 3/5] fix(captcha): list page and task ids in status and filter by --page-id --- docs/cli-reference.md | 10 +-- docs/references/steel-browser.md | 22 +++++- src/commands/browser/captcha.rs | 131 ++++++++++++++++++++++++++++--- 3 files changed, 148 insertions(+), 15 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7674227..6be386c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -296,7 +296,7 @@ steel browser captcha ### Subcommands - `solve`: Solve every detected CAPTCHA, or the one a flag picks -- `status`: Check CAPTCHA status +- `status`: Check CAPTCHA status and list page and task ids ## steel browser captcha solve @@ -310,13 +310,13 @@ steel browser captcha solve ### Parameters -- `--page-id` (string, optional): Solve only the CAPTCHA on this page +- `--page-id` (string, optional): Solve only the CAPTCHA on this page (ids from `captcha status`) - `--url` (string, optional): Solve only the CAPTCHA on the page at this URL -- `--task-id` (string, optional): Solve only this CAPTCHA task +- `--task-id` (string, optional): Solve only this CAPTCHA task (ids from `captcha status`) ## steel browser captcha status -Check CAPTCHA status +Check CAPTCHA status and list page and task ids ### Usage @@ -326,7 +326,7 @@ steel browser captcha status ### Parameters -- `--page-id` (string, optional): Page ID +- `--page-id` (string, optional): Check only this page - `-w, --wait` (boolean, optional): Wait for terminal status - `--timeout` (string, optional): Timeout in milliseconds for --wait mode - `--interval` (string, optional): Poll interval in milliseconds for --wait mode diff --git a/docs/references/steel-browser.md b/docs/references/steel-browser.md index 90a4c49..7ca6b95 100644 --- a/docs/references/steel-browser.md +++ b/docs/references/steel-browser.md @@ -131,7 +131,8 @@ Main flags: Purpose: manually trigger CAPTCHA solving for a target session. Without flags it solves every CAPTCHA detected in the session. `--page-id`, `--url`, or -`--task-id` solves only the one it names. +`--task-id` solves only the one it names. `steel browser captcha status` lists +the page and task ids. Main flags: @@ -148,6 +149,25 @@ API mapping: - Request body: optional `pageId`, `url`, `taskId` - Response: `success` + optional `message` +### `steel browser captcha status` + +Purpose: print the overall CAPTCHA status, then one line per page and per task: + +```text +solving turnstile +page 9F3C2A1B https://example.com/login + task 7d1e turnstile solving +``` + +Pass the page id to `--page-id` and the task id to `--task-id`. `--page-id` on +`status` checks only that page. `--json` returns the same ids under `data.pages`. + +Main flags: + +- `--session ` +- `--page-id ` +- `-w, --wait`, `--timeout `, `--interval ` + ## Profile Persistence Profiles let sessions start with pre-existing browser state (cookies, local storage, IndexedDB, etc.) imported from a local Chrome installation. diff --git a/src/commands/browser/captcha.rs b/src/commands/browser/captcha.rs index 456894d..39edae0 100644 --- a/src/commands/browser/captcha.rs +++ b/src/commands/browser/captcha.rs @@ -10,7 +10,7 @@ pub enum Command { /// Solve every detected CAPTCHA, or the one a flag picks Solve(SolveArgs), - /// Check CAPTCHA status + /// Check CAPTCHA status and list page and task ids Status(StatusArgs), } @@ -25,7 +25,7 @@ impl Command { #[derive(Parser)] pub struct SolveArgs { - /// Solve only the CAPTCHA on this page + /// Solve only the CAPTCHA on this page (ids from `captcha status`) #[arg(long)] pub page_id: Option, @@ -33,14 +33,14 @@ pub struct SolveArgs { #[arg(long)] pub url: Option, - /// Solve only this CAPTCHA task + /// Solve only this CAPTCHA task (ids from `captcha status`) #[arg(long)] pub task_id: Option, } #[derive(Parser)] pub struct StatusArgs { - /// Page ID + /// Check only this page #[arg(long)] pub page_id: Option, @@ -121,17 +121,20 @@ async fn run_solve(args: SolveArgs, session: Option<&str>) -> anyhow::Result<()> async fn run_status(args: StatusArgs, session: Option<&str>) -> anyhow::Result<()> { let (client, target) = client_for(session)?; let session_id = target.id; + let page_id = args + .page_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()); let timeout = args.timeout.unwrap_or(60_000); let interval = args.interval.unwrap_or(1_000); let started = std::time::Instant::now(); - let (status, types) = loop { - let pages = client - .captcha_status(&session_id, args.page_id.as_deref()) - .await?; + let (status, types, pages) = loop { + let pages = on_page(client.captcha_status(&session_id, page_id).await?, page_id); let (status, types) = normalize_captcha_status(&pages); if !args.wait || is_terminal_captcha_status(&status) { - break (status, types); + break (status, types, page_summaries(&pages)); } if started.elapsed().as_millis() >= u128::from(timeout) { anyhow::bail!( @@ -147,6 +150,7 @@ async fn run_status(args: StatusArgs, session: Option<&str>) -> anyhow::Result<( if !types.is_empty() { data["types"] = json!(types); } + data["pages"] = json!(pages); if !output::is_json() { let type_suffix = if types.is_empty() { @@ -155,6 +159,9 @@ async fn run_status(args: StatusArgs, session: Option<&str>) -> anyhow::Result<( format!(" {}", types.join(",")) }; println!("{status}{type_suffix}"); + for line in page_lines(&pages) { + println!("{line}"); + } } if status != "solved" && status != "none" { @@ -250,6 +257,54 @@ fn is_terminal_captcha_status(status: &str) -> bool { matches!(status, "solved" | "failed" | "none") } +fn on_page(pages: Vec, page_id: Option<&str>) -> Vec { + match page_id { + Some(id) => pages + .into_iter() + .filter(|page| page["pageId"] == id) + .collect(), + None => pages, + } +} + +fn page_summaries(pages: &[Value]) -> Vec { + pages + .iter() + .map(|page| { + let tasks: Vec = page["tasks"] + .as_array() + .into_iter() + .flatten() + .filter(|task| task.is_object()) + .map(|task| json!({"id": task["id"], "type": task["type"], "status": task["status"]})) + .collect(); + json!({"pageId": page["pageId"], "url": page["url"], "tasks": tasks}) + }) + .collect() +} + +fn page_lines(pages: &[Value]) -> Vec { + let text = |value: &Value| value.as_str().unwrap_or("?").to_string(); + let mut lines = Vec::new(); + for page in pages { + let mut line = format!("page {}", text(&page["pageId"])); + if let Some(url) = page["url"].as_str() { + line.push(' '); + line.push_str(url); + } + lines.push(line); + for task in page["tasks"].as_array().into_iter().flatten() { + lines.push(format!( + " task {} {} {}", + text(&task["id"]), + text(&task["type"]), + text(&task["status"]) + )); + } + } + lines +} + #[cfg(test)] mod tests { use super::*; @@ -290,4 +345,62 @@ mod tests { assert_eq!(status, "failed"); assert_eq!(types, vec!["turnstile"]); } + + fn two_pages() -> Vec { + vec![ + json!({ + "pageId": "p-1", + "url": "https://a.test/login", + "isSolvingCaptcha": true, + "tasks": [{"id": "k-1", "pageId": "p-1", "type": "turnstile", "status": "solving", "created": 1}] + }), + json!({ + "pageId": "p-2", + "url": "https://b.test/", + "isSolvingCaptcha": false, + "tasks": [{"id": "k-2", "pageId": "p-2", "type": "recaptchaV2", "status": "solved"}] + }), + ] + } + + #[test] + fn status_lists_page_and_task_ids() { + let pages = page_summaries(&two_pages()); + assert_eq!( + pages, + vec![ + json!({"pageId": "p-1", "url": "https://a.test/login", "tasks": [ + {"id": "k-1", "type": "turnstile", "status": "solving"} + ]}), + json!({"pageId": "p-2", "url": "https://b.test/", "tasks": [ + {"id": "k-2", "type": "recaptchaV2", "status": "solved"} + ]}), + ] + ); + assert_eq!( + page_lines(&pages), + vec![ + "page p-1 https://a.test/login", + " task k-1 turnstile solving", + "page p-2 https://b.test/", + " task k-2 recaptchaV2 solved", + ] + ); + } + + #[test] + fn page_id_keeps_only_that_page() { + let pages = on_page(two_pages(), Some("p-2")); + assert_eq!(pages.len(), 1); + assert_eq!(normalize_captcha_status(&pages).0, "solved"); + assert_eq!(normalize_captcha_status(&two_pages()).0, "solving"); + assert!(on_page(two_pages(), Some("p-9")).is_empty()); + assert_eq!(on_page(two_pages(), None).len(), 2); + } + + #[test] + fn page_lines_tolerate_missing_fields() { + let pages = page_summaries(&[json!({"pageId": "p-1", "tasks": [{"status": "detected"}]})]); + assert_eq!(page_lines(&pages), vec!["page p-1", " task ? ? detected"]); + } } From c61c575847054021cf972d5f5405439cd380bdb4 Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 12 Nov 2026 19:27:19 +0900 Subject: [PATCH 4/5] fix(browser): stop sending deprecated command forms --- docs/references/steel-browser-commands.md | 2 +- docs/references/steel-browser.md | 3 + src/api/commands.rs | 103 ++++++++++++++----- src/browser/state.rs | 7 +- src/commands/browser/action.rs | 119 +++++++--------------- tests/browser_commands.rs | 46 ++++++++- 6 files changed, 161 insertions(+), 119 deletions(-) diff --git a/docs/references/steel-browser-commands.md b/docs/references/steel-browser-commands.md index 554c997..804a3a2 100644 --- a/docs/references/steel-browser-commands.md +++ b/docs/references/steel-browser-commands.md @@ -109,7 +109,7 @@ steel browser tab switch t2 steel browser tab close [t2] # the current tab if omitted ``` -Commands without a ref run on the current tab. When a page opens a tab, the CLI prints `Tab t3 opened from t1` and stays on the current tab. +Commands without a ref run on the current tab. When a page opens a tab, the CLI stays on the current tab. Run `steel browser tab list` to find the new one. ## Dialogs and long commands diff --git a/docs/references/steel-browser.md b/docs/references/steel-browser.md index 7ca6b95..8b59e4d 100644 --- a/docs/references/steel-browser.md +++ b/docs/references/steel-browser.md @@ -7,6 +7,7 @@ This reference defines how `steel browser` runs and the contracts of its session Each `steel browser` action is one HTTP request to the session's commands API: - `POST /v1/sessions/{sessionId}/commands` with the API key, a random `Idempotency-Key`, and `after` (the last operation id the CLI received). +- `steel browser status` and `steel browser cancel` use `GET /v1/sessions/{sessionId}/commands/executions/{executionId}` and `POST .../cancel`. The CLI also uses the `GET` to wait for a command that is still running. - The browser engine runs inside the session next to Chrome. It holds refs, tabs, dialogs, and the execution records. - The CLI keeps no process running. It stores per-session state in `~/.config/steel/browser-sessions/.json`. @@ -195,6 +196,8 @@ Profile metadata is stored at `~/.config/steel/profiles/.json`. Self-hosted steel-browser serves the commands API only when it runs with `ENABLE_COMMANDS_API=true`. Without it, browser actions fail with `missing_capability`. `steel dev start` does not turn it on yet. +It also needs steel-browser from 2026-11-09 or later. Older builds do not +serve the execution routes or the `find` target form. ## Troubleshooting (Agent-Focused) diff --git a/src/api/commands.rs b/src/api/commands.rs index 358bb22..0e73a86 100644 --- a/src/api/commands.rs +++ b/src/api/commands.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use serde_json::{Value, json}; +use serde_json::Value; use crate::api::client::{ApiError, SteelClient}; @@ -63,14 +63,27 @@ impl Session<'_> { } async fn post(&self, body: &Value, key: Option<&str>) -> Result { - let path = self.path("/commands"); + self.call(reqwest::Method::POST, "/commands", Some(body), key) + .await + } + + async fn call( + &self, + method: reqwest::Method, + suffix: &str, + body: Option<&Value>, + key: Option<&str>, + ) -> Result { + let path = self.path(suffix); let url = self.client.url(&path); let mut req = self .client - .authed(reqwest::Method::POST, &path) + .authed(method, &path) .await? - .timeout(REQUEST_TIMEOUT) - .json(body); + .timeout(REQUEST_TIMEOUT); + if let Some(body) = body { + req = req.json(body); + } if let Some(key) = key { req = req.header("Idempotency-Key", key); } @@ -137,20 +150,26 @@ impl Session<'_> { key = new_key(); renewed = true; } - (200, _) => return self.settle(reply, body.get("eventsAfter")).await, + (200, _) => return self.settle(reply).await, _ => return Ok(reply), } } } - pub async fn control(&self, command: Value, events: Option<&Value>) -> Result { - let mut body = json!({ "commands": [command] }); - if let Some(cursor) = events { - body["eventsAfter"] = cursor.clone(); - } + pub async fn execution(&self, id: &str) -> Result { + let suffix = format!("/commands/executions/{}", urlencoding::encode(id)); + self.control(reqwest::Method::GET, &suffix).await + } + + pub async fn cancel(&self, id: &str) -> Result { + let suffix = format!("/commands/executions/{}/cancel", urlencoding::encode(id)); + self.control(reqwest::Method::POST, &suffix).await + } + + async fn control(&self, method: reqwest::Method, suffix: &str) -> Result { let mut delivery = 0; loop { - let result = self.post(&body, None).await; + let result = self.call(method.clone(), suffix, None, None).await; let retry = match &result { Err(ApiError::Unreachable { .. }) => true, Ok(reply) => reply.status == 503, @@ -164,17 +183,16 @@ impl Session<'_> { } } - async fn settle(&self, mut reply: Reply, events: Option<&Value>) -> Result { + async fn settle(&self, mut reply: Reply) -> Result { for _ in 0..STATUS_POLLS { if !reply.is_pending() { return Ok(reply); } - let Some(execution) = reply.body["execution"]["id"].as_str() else { + let Some(execution) = reply.body["execution"]["id"].as_str().map(str::to_string) else { return Ok(reply); }; let next = reply.body.get("next").cloned(); - let status = json!({ "command": "status", "execution": execution }); - let mut polled = self.control(status, events).await?; + let mut polled = self.execution(&execution).await?; if polled.status != 200 { return Ok(polled); } @@ -270,6 +288,7 @@ mod tests { use super::*; use crate::config::auth::{Auth, AuthSource}; use crate::config::settings::ApiMode; + use serde_json::json; use wiremock::matchers::{body_partial_json, header, header_exists, method, path}; use wiremock::{Mock, MockServer, Request, ResponseTemplate}; @@ -472,45 +491,75 @@ mod tests { } #[tokio::test] - async fn waits_for_a_running_execution_with_status() { + async fn waits_for_a_running_execution_on_its_route() { let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path(commands_path())) - .and(body_partial_json(json!({"commands": [{"command": "status", "execution": "x-7"}]}))) + Mock::given(method("GET")) + .and(path(format!("/sessions/{ID}/commands/executions/x-7"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "epoch": 2, "page": null, "execution": {"id": "x-7", "state": "completed", "effect": "none", "replayed": false, "ran": 1}, - "results": [{"ok": true, "effect": "none", "waited": "text"}], - "events": {"cursor": "c-9", "items": []} + "results": [{"ok": true, "effect": "none", "waited": "text"}] }))) + .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path(commands_path())) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "next": "op_9", - "epoch": 2, "page": null, "execution": {"id": "x-7", "state": "running", "effect": "none", "replayed": false, "ran": 1}, "results": [] }))) + .expect(1) .mount(&server) .await; let reply = run( &server, - json!({"commands": [{"command": "wait", "text": "Done"}], "eventsAfter": "c-3"}), + json!({"commands": [{"command": "wait", "text": "Done"}]}), ) .await .unwrap(); assert_eq!(reply.body["execution"]["state"], "completed"); assert_eq!(reply.body["next"], "op_9"); let requests = server.received_requests().await.unwrap(); - let status: Value = serde_json::from_slice(&requests[1].body).unwrap(); - assert_eq!(status["eventsAfter"], "c-3"); assert!(requests[1].headers.get("idempotency-key").is_none()); } + #[tokio::test] + async fn cancels_on_the_execution_route_and_retries_delivery() { + let server = MockServer::start().await; + let route = format!("/sessions/{ID}/commands/executions/x-7/cancel"); + Mock::given(method("POST")) + .and(path(route.clone())) + .respond_with(ResponseTemplate::new(503).set_body_json( + json!({"error": {"code": "delivery_failed", "message": "unreachable"}}), + )) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path(route)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "page": null, + "execution": {"id": "x-7", "state": "cancelled", "effect": "none", "replayed": false, "ran": 0}, + "results": [] + }))) + .mount(&server) + .await; + let client = SteelClient::with(server.uri(), ApiMode::Cloud, auth()).unwrap(); + let session = Session { + client: &client, + id: ID, + }; + let reply = session.cancel("x-7").await.unwrap(); + assert_eq!(reply.status, 200); + assert_eq!(reply.body["execution"]["state"], "cancelled"); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2); + assert!(requests.iter().all(|r| r.body.is_empty())); + } + #[tokio::test] async fn passes_session_errors_through() { let server = MockServer::start().await; diff --git a/src/browser/state.rs b/src/browser/state.rs index 096434d..d3391c5 100644 --- a/src/browser/state.rs +++ b/src/browser/state.rs @@ -41,8 +41,6 @@ pub struct BrowserSession { #[serde(default, skip_serializing_if = "Option::is_none")] pub tab: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub events: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub snapshot: Option, } @@ -61,7 +59,6 @@ impl BrowserSession { created_at_ms: None, next: None, tab: None, - events: None, snapshot: None, } } @@ -243,11 +240,11 @@ mod tests { } #[test] - fn state_files_with_a_saved_dialog_still_load() { + fn state_files_with_retired_fields_still_load() { let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join("x.json"), - r#"{"name":"x","id":"abc","mode":"cloud","apiUrl":"https://api.steel.dev/v1","tab":"t1","dialog":"d-2"}"#, + r#"{"name":"x","id":"abc","mode":"cloud","apiUrl":"https://api.steel.dev/v1","tab":"t1","dialog":"d-2","events":"c-4"}"#, ) .unwrap(); let loaded = load_in("x", dir.path()).unwrap().unwrap(); diff --git a/src/commands/browser/action.rs b/src/commands/browser/action.rs index 64481a9..77585c1 100644 --- a/src/commands/browser/action.rs +++ b/src/commands/browser/action.rs @@ -550,7 +550,12 @@ impl Step { pub enum Plan { Run(Step), - Control(Value), + Control(Control), +} + +pub enum Control { + Status(String), + Cancel(String), } struct Cmd(Map); @@ -792,11 +797,14 @@ impl ActionCommand { ), Self::Eval(a) => single(cmd("eval").set("script", a.script), O::Value), Self::Find(a) => single( - cmd("find") - .set("by", a.by) - .set("value", a.value) - .opt("name", a.name) - .on("exact", a.exact), + cmd("find").set( + "target", + Cmd(Map::new()) + .set(&a.by, a.value) + .opt("name", a.name) + .on("exact", a.exact) + .done(), + ), O::Find, ), Self::Content => single( @@ -846,12 +854,8 @@ impl ActionCommand { single(cmd("tab").set("action", "close").opt("tab", a.tab), O::None) } }, - Self::Status(a) => Ok(Plan::Control( - cmd("status").set("execution", a.execution).done(), - )), - Self::Cancel(a) => Ok(Plan::Control( - cmd("cancel").set("execution", a.execution).done(), - )), + Self::Status(a) => Ok(Plan::Control(Control::Status(a.execution))), + Self::Cancel(a) => Ok(Plan::Control(Control::Cancel(a.execution))), Self::Cookies(_) => Err(removed( "cookies", "Read cookies with `steel browser eval \"document.cookie\"` (HttpOnly cookies are not visible). Keep logins across sessions with `steel browser start --profile `.", @@ -960,7 +964,7 @@ fn scoped(mut command: Value, tab: Option<&str>) -> Value { return command; }; let eligible = match map.get("command").and_then(Value::as_str) { - Some("status" | "cancel") | None => false, + None => false, Some("tab") => map.get("action").and_then(Value::as_str) == Some("close"), Some(_) => true, }; @@ -1050,12 +1054,7 @@ fn failure(body: &Value) -> Option<(String, String)> { )) } -fn notices( - body: &Value, - events: bool, - current_tab: Option<&str>, - downloading: bool, -) -> Vec { +fn notices(body: &Value) -> Vec { let mut notes = Vec::new(); if body["execution"]["replayed"] == true { notes.push("This is the stored result of an earlier run. Nothing ran again.".to_string()); @@ -1092,31 +1091,6 @@ fn notices( if let Some(message) = body["observe"]["error"]["message"].as_str() { notes.push(format!("Could not take the diff: {message}")); } - if !events { - return notes; - } - for item in body["events"]["items"].as_array().into_iter().flatten() { - let tab = item["tab"].as_str().unwrap_or("?"); - match item["type"].as_str() { - Some("tab_opened") if Some(tab) != current_tab => { - let from = item["opener"] - .as_str() - .map(|opener| format!(" from {opener}")) - .unwrap_or_default(); - notes.push(format!( - "Tab {tab} opened{from}. Switch to it with `steel browser tab switch {tab}`." - )); - } - Some("tab_closed") => notes.push(format!("Tab {tab} closed.")), - Some("download") if !downloading && item["state"] == "completed" => { - notes.push(format!( - "Downloaded {}. Save it with `steel browser wait --download`.", - item["file"].as_str().unwrap_or("a file") - )); - } - _ => {} - } - } notes } @@ -1144,12 +1118,6 @@ fn tab_line(tab: &Value, current: Option<&str>) -> String { ) } -fn absorb_page(state: &mut BrowserSession, body: &Value) { - if let Some(cursor) = body["events"]["cursor"].as_str() { - state.events = Some(cursor.to_string()); - } -} - fn restarted(body: &Value) -> bool { body["execution"]["error"]["code"] == "browser_restarted" || body["results"].as_array().is_some_and(|results| { @@ -1216,10 +1184,7 @@ impl Browser { .flat_map(|step| step.commands.iter().cloned()) .map(|command| scoped(command, self.state.tab.as_deref())) .collect(); - let mut body = json!({ - "commands": commands, - "eventsAfter": self.state.events.as_deref().unwrap_or("c-0"), - }); + let mut body = json!({ "commands": commands }); if let Some(next) = &self.state.next { body["after"] = json!(next); } @@ -1323,7 +1288,6 @@ impl Browser { if let Some(tab) = body["page"]["tab"].as_str() { self.state.tab = Some(tab.to_string()); } - absorb_page(&mut self.state, body); let results = body["results"] .as_array() .map(Vec::as_slice) @@ -1355,7 +1319,6 @@ impl Browser { .with_context(|| format!("Could not read {}", path.display()))?; self.send(self.session().upload_file(bytes, name)).await?; } - let events = self.state.events.is_some(); let tab_commands = steps .iter() .flat_map(|step| step.commands.iter()) @@ -1378,17 +1341,14 @@ impl Browser { retried = true; continue; } - let result = self.report(&steps, &mut reply.body, events).await; + let result = self.report(&steps, &mut reply.body).await; self.save()?; return result; } } - async fn report(&mut self, steps: &[Step], body: &mut Value, events: bool) -> Result<()> { - let downloading = steps - .iter() - .any(|s| matches!(s.output, Output::Download(_))); - let notes = notices(body, events, self.state.tab.as_deref(), downloading); + async fn report(&mut self, steps: &[Step], body: &mut Value) -> Result<()> { + let notes = notices(body); let failed = failure(body); let mut lines = Vec::new(); let mut index = 0; @@ -1515,12 +1475,13 @@ impl Browser { }) } - pub async fn control(&mut self, command: Value) -> Result<()> { - let reply = self.send(self.session().control(command, None)).await?; + pub async fn control(&mut self, control: Control) -> Result<()> { + let reply = match &control { + Control::Status(id) => self.send(self.session().execution(id)).await?, + Control::Cancel(id) => self.send(self.session().cancel(id)).await?, + }; let reply = self.check(reply)?; let body = reply.body; - absorb_page(&mut self.state, &body); - self.save()?; let failed = failure(&body); if output::is_json() { return match failed { @@ -1554,9 +1515,9 @@ pub async fn run(action: ActionCommand, session: Option<&str>, diff: bool) -> Re let mut browser = Browser::open(session, true).await?; browser.execute(vec![step], diff).await } - Plan::Control(command) => { + Plan::Control(control) => { let mut browser = Browser::open(session, false).await?; - browser.control(command).await + browser.control(control).await } } } @@ -1724,14 +1685,14 @@ mod tests { fn find_defaults_to_css() { assert_eq!( commands(&["find", "button.primary"]), - vec![json!({"command": "find", "by": "css", "value": "button.primary"})] + vec![json!({"command": "find", "target": {"css": "button.primary"}})] ); assert_eq!( commands(&[ "find", "button", "--by", "role", "--name", "Save", "--exact" ]), vec![ - json!({"command": "find", "by": "role", "value": "button", "name": "Save", "exact": true}) + json!({"command": "find", "target": {"role": "button", "name": "Save", "exact": true}}) ] ); } @@ -1774,11 +1735,11 @@ mod tests { fn control_commands() { assert!(matches!( plan(&["status", "x-7"]).unwrap(), - Plan::Control(ref c) if *c == json!({"command": "status", "execution": "x-7"}) + Plan::Control(Control::Status(ref id)) if id == "x-7" )); assert!(matches!( plan(&["cancel", "x-7"]).unwrap(), - Plan::Control(ref c) if *c == json!({"command": "cancel", "execution": "x-7"}) + Plan::Control(Control::Cancel(ref id)) if id == "x-7" )); } @@ -1961,26 +1922,20 @@ mod tests { } #[test] - fn notices_cover_replays_dialogs_and_popups() { + fn notices_cover_replays_dialogs_and_loading() { let body = json!({ "execution": {"state": "completed", "replayed": true}, "dialog": {"id": "d-2", "type": "confirm", "tab": "t1", "message": "Sure?"}, - "results": [{"ok": true, "loading": true}], - "events": {"cursor": "c-5", "items": [ - {"type": "tab_opened", "tab": "t3", "opener": "t1"}, - {"type": "navigation", "tab": "t1"}, - ]} + "results": [{"ok": true, "loading": true}] }); - let notes = notices(&body, true, Some("t1"), false); + let notes = notices(&body); assert!(notes.iter().any(|n| n.contains("Nothing ran again"))); assert!(notes.iter().any(|n| n.contains("Dialog d-2 (confirm)") && n.contains("steel browser live") && n.contains("steel browser tab close t1"))); assert!(!notes.iter().any(|n| n.contains("steel browser dialog"))); assert!(notes.iter().any(|n| n.contains("still loading"))); - assert!(notes.iter().any(|n| n.contains("tab switch t3"))); - assert_eq!(notes.len(), 4); - assert_eq!(notices(&body, false, Some("t1"), false).len(), 3); + assert_eq!(notes.len(), 3); } #[test] diff --git a/tests/browser_commands.rs b/tests/browser_commands.rs index db11a03..eae6339 100644 --- a/tests/browser_commands.rs +++ b/tests/browser_commands.rs @@ -167,7 +167,7 @@ async fn start_act_and_stop_without_a_daemon() { let state = cli.state("work").unwrap(); assert_eq!(state["next"], "op_1"); assert_eq!(state["tab"], "t1"); - assert_eq!(state["events"], "c-4"); + assert!(state.get("events").is_none()); let snap = cli .run(&["browser", "snapshot", "-i", "--session", "work"]) @@ -185,14 +185,13 @@ async fn start_act_and_stop_without_a_daemon() { let bodies = command_bodies(&server).await; assert_eq!( bodies[0], - json!({"commands": [{"command": "click", "target": "e5"}], "eventsAfter": "c-0"}) + json!({"commands": [{"command": "click", "target": "e5"}]}) ); assert_eq!( bodies[1], json!({ "after": "op_1", - "commands": [{"command": "snapshot", "interactive": true, "tab": "t1"}], - "eventsAfter": "c-4" + "commands": [{"command": "snapshot", "interactive": true, "tab": "t1"}] }) ); let keys: Vec<_> = server @@ -211,6 +210,45 @@ async fn start_act_and_stop_without_a_daemon() { assert!(cli.state("work").is_none()); } +#[tokio::test] +async fn status_and_cancel_use_the_execution_routes() { + let server = MockServer::start().await; + let execution = |state: &str| { + json!({ + "page": null, + "execution": {"id": "x-7", "state": state, "effect": "none", "replayed": false, "ran": 1}, + "results": [] + }) + }; + Mock::given(method("GET")) + .and(path(format!("/v1/sessions/{ID}/commands/executions/x-7"))) + .respond_with(ResponseTemplate::new(200).set_body_json(execution("completed"))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path(format!( + "/v1/sessions/{ID}/commands/executions/x-7/cancel" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(execution("cancelled"))) + .expect(1) + .mount(&server) + .await; + + let cli = Cli::new(&server); + cli.write_state("work"); + + let status = cli + .run(&["browser", "status", "x-7", "--session", "work"]) + .await; + assert!(status.status.success(), "{status:?}"); + assert_eq!(json_out(&status)["data"]["execution"]["state"], "completed"); + + cli.run(&["browser", "cancel", "x-7", "--session", "work"]) + .await; + assert!(command_bodies(&server).await.is_empty()); +} + #[tokio::test] async fn diff_observes_against_the_last_snapshot() { let server = MockServer::start().await; From 6592dc683d0d73db736b4def1919f6bf2f0494e6 Mon Sep 17 00:00:00 2001 From: junhsss Date: Thu, 12 Nov 2026 23:53:41 +0900 Subject: [PATCH 5/5] fix(browser): suggest a new tab before closing a dialog's tab --- docs/references/agent-troubleshooting.md | 3 ++- docs/references/steel-browser.md | 3 ++- src/commands/browser/action.rs | 12 ++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/references/agent-troubleshooting.md b/docs/references/agent-troubleshooting.md index 5674a53..1c15aa2 100644 --- a/docs/references/agent-troubleshooting.md +++ b/docs/references/agent-troubleshooting.md @@ -89,7 +89,8 @@ steel browser start --local --session "$SESSION_NAME" - `tab_required`: several tabs are open. Run `steel browser tab list`, then `steel browser tab switch `. - `dialog_pending`: commands cannot answer dialogs. Close it in the live view - (`steel browser live`), or run `steel browser tab close `. + (`steel browser live`), or run `steel browser tab new`, then + `steel browser tab close `. - `delivery_failed`: the API could not reach the browser. Run the same command again; it will not run twice. - Stale state: diff --git a/docs/references/steel-browser.md b/docs/references/steel-browser.md index 8b59e4d..bb50a59 100644 --- a/docs/references/steel-browser.md +++ b/docs/references/steel-browser.md @@ -211,7 +211,8 @@ serve the execution routes or the `find` target form. - `stale_ref`: take a new snapshot (`steel browser snapshot -i`) and use the new refs. - `tab_required`: several tabs are open. Pick one with `steel browser tab switch `. - `dialog_pending`: a dialog blocks the tab, and commands cannot answer it. Close - it in the live view (`steel browser live`), or run `steel browser tab close `. + it in the live view (`steel browser live`), or run `steel browser tab new`, then + `steel browser tab close `. - `This is the stored result of an earlier run`: the same command already ran after the last response the CLI received. Nothing ran again. - `delivery_failed`: the API could not reach the browser. Run the same command diff --git a/src/commands/browser/action.rs b/src/commands/browser/action.rs index 77585c1..a034a24 100644 --- a/src/commands/browser/action.rs +++ b/src/commands/browser/action.rs @@ -889,7 +889,7 @@ impl ActionCommand { "Use `steel browser fill `.", )), Self::Dialog(_) => Err(anyhow!( - "`steel browser dialog` was removed because commands can no longer answer dialogs. Close the dialog in the live view (`steel browser live`), or run `steel browser tab close `." + "`steel browser dialog` was removed because commands can no longer answer dialogs. Close the dialog in the live view (`steel browser live`), or run `steel browser tab new`, then `steel browser tab close `." )), } } @@ -1010,7 +1010,7 @@ fn hint(code: &str, message: &str) -> &'static str { "tab_required" => " Run `steel browser tab switch `.", "tab_not_found" => " Run `steel browser tab list`.", "dialog_pending" => { - " Run `steel browser live` to open the live view, or `steel browser tab close `." + " Run `steel browser live` to open the live view, or run `steel browser tab new`, then `steel browser tab close `." } "browser_restarted" => " Run `steel browser tab list`, then `steel browser snapshot -i`.", "missing_capability" => " Run `steel browser start`.", @@ -1082,7 +1082,7 @@ fn notices(body: &Value) -> Vec { if let Some(dialog) = body.get("dialog").filter(|d| d.is_object()) { let tab = dialog["tab"].as_str().unwrap_or(""); notes.push(format!( - "Dialog {} ({}) is open on {tab}: {}. Commands cannot answer it. Close it in the live view (`steel browser live`), or run `steel browser tab close {tab}`.", + "Dialog {} ({}) is open on {tab}: {}. Commands cannot answer it. Close it in the live view (`steel browser live`), or run `steel browser tab new`, then `steel browser tab close {tab}`.", dialog["id"].as_str().unwrap_or("?"), dialog["type"].as_str().unwrap_or("dialog"), dialog["message"], @@ -1759,7 +1759,7 @@ mod tests { "{args:?}: {message}" ); assert!( - message.contains("steel browser tab close"), + message.contains("steel browser tab new`, then `steel browser tab close"), "{args:?}: {message}" ); } @@ -1916,7 +1916,7 @@ mod tests { let (code, message) = failure(&blocked).unwrap(); assert_eq!(code, "dialog_pending"); assert!( - message.ends_with("Run `steel browser live` to open the live view, or `steel browser tab close `."), + message.ends_with("Run `steel browser live` to open the live view, or run `steel browser tab new`, then `steel browser tab close `."), "{message}" ); } @@ -1932,7 +1932,7 @@ mod tests { assert!(notes.iter().any(|n| n.contains("Nothing ran again"))); assert!(notes.iter().any(|n| n.contains("Dialog d-2 (confirm)") && n.contains("steel browser live") - && n.contains("steel browser tab close t1"))); + && n.contains("steel browser tab new`, then `steel browser tab close t1"))); assert!(!notes.iter().any(|n| n.contains("steel browser dialog"))); assert!(notes.iter().any(|n| n.contains("still loading"))); assert_eq!(notes.len(), 3);