diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 3fa2da6..6be386c 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 @@ -297,12 +295,12 @@ steel browser captcha ### Subcommands -- `solve`: Solve a CAPTCHA on the current page -- `status`: Check CAPTCHA status +- `solve`: Solve every detected CAPTCHA, or the one a flag picks +- `status`: Check CAPTCHA status and list page and task ids ## steel browser captcha solve -Solve a CAPTCHA on the current page +Solve every detected CAPTCHA, or the one a flag picks ### Usage @@ -312,13 +310,13 @@ 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 (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 (ids from `captcha status`) ## steel browser captcha status -Check CAPTCHA status +Check CAPTCHA status and list page and task ids ### Usage @@ -328,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 @@ -774,7 +772,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 +868,7 @@ steel browser get title ## steel browser get count -Count matching elements +Count elements that match a CSS selector ### Usage @@ -880,7 +878,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 @@ -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..1c15aa2 100644 --- a/docs/references/agent-troubleshooting.md +++ b/docs/references/agent-troubleshooting.md @@ -88,7 +88,9 @@ 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 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-commands.md b/docs/references/steel-browser-commands.md index 7412a57..804a3a2 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 @@ -109,17 +109,16 @@ 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 ```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 51b0c23..bb50a59 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`. @@ -129,7 +130,10 @@ 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. `steel browser captcha status` lists +the page and task ids. Main flags: @@ -146,6 +150,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. @@ -173,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) @@ -185,7 +210,9 @@ 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 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/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 e95fa67..d3391c5 100644 --- a/src/browser/state.rs +++ b/src/browser/state.rs @@ -41,10 +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 dialog: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub snapshot: Option, } @@ -63,8 +59,6 @@ impl BrowserSession { created_at_ms: None, next: None, tab: None, - events: None, - dialog: None, snapshot: None, } } @@ -245,6 +239,18 @@ mod tests { assert!(!loaded.attached); } + #[test] + 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","events":"c-4"}"#, + ) + .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 68537a5..a034a24 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)] @@ -116,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 @@ -139,6 +143,8 @@ pub enum ActionCommand { ScrollIntoView(RemovedArgs), #[command(name = "setvalue", hide = true)] SetValue(RemovedArgs), + #[command(hide = true)] + Dialog(RemovedArgs), } impl ActionCommand { @@ -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), @@ -491,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 @@ -528,8 +522,6 @@ pub enum Output { Switch(String), Saved(PathBuf), Download(Option), - Execution, - Dialog, } pub struct Step { @@ -558,7 +550,12 @@ impl Step { pub enum Plan { Run(Step), - Control(Value, Output), + Control(Control), +} + +pub enum Control { + Status(String), + Cancel(String), } struct Cmd(Map); @@ -800,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( @@ -854,22 +854,8 @@ 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::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 `.", @@ -902,6 +888,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 new`, then `steel browser tab close `." + )), } } } @@ -975,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" | "dialog") | None => false, + None => false, Some("tab") => map.get("action").and_then(Value::as_str) == Some("close"), Some(_) => true, }; @@ -1020,7 +1009,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 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`.", "invalid_operation_token" => " If it did not run, run it again.", @@ -1063,19 +1054,14 @@ 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()); } 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(), ); } @@ -1094,42 +1080,17 @@ 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 new`, then `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"], )); } 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 } @@ -1157,15 +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()); - } - if let Some(dialog) = body["dialog"]["id"].as_str() { - state.dialog = Some(dialog.to_string()); - } -} - fn restarted(body: &Value) -> bool { body["execution"]["error"]["code"] == "browser_restarted" || body["results"].as_array().is_some_and(|results| { @@ -1232,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); } @@ -1324,9 +1273,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, @@ -1342,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) @@ -1374,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()) @@ -1397,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; @@ -1461,7 +1402,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)), @@ -1534,25 +1475,13 @@ 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, 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); - if matches!(output, Output::Dialog) { - self.state.dialog = None; - } - self.save()?; let failed = failure(&body); if output::is_json() { return match failed { @@ -1563,24 +1492,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(()), @@ -1595,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, output) => { + Plan::Control(control) => { let mut browser = Browser::open(session, false).await?; - browser.control(command, output).await + browser.control(control).await } } } @@ -1765,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}}) ] ); } @@ -1814,16 +1734,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(Control::Status(ref id)) if id == "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(Control::Cancel(ref id)) if id == "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 new`, then `steel browser tab close"), + "{args:?}: {message}" + ); + } + } + #[test] fn removed_commands_explain_what_to_use() { for args in [ @@ -1964,26 +1905,37 @@ 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 run `steel browser tab new`, then `steel browser tab close `."), + "{message}" + ); } #[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)"))); + assert!(notes.iter().any(|n| n.contains("Dialog d-2 (confirm)") + && n.contains("steel browser live") + && 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!(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/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()], diff --git a/src/commands/browser/captcha.rs b/src/commands/browser/captcha.rs index a966399..39edae0 100644 --- a/src/commands/browser/captcha.rs +++ b/src/commands/browser/captcha.rs @@ -7,10 +7,10 @@ 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 + /// Check CAPTCHA status and list page and task ids Status(StatusArgs), } @@ -25,22 +25,22 @@ impl Command { #[derive(Parser)] pub struct SolveArgs { - /// Page ID + /// Solve only the CAPTCHA on this page (ids from `captcha status`) #[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 (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"]); + } } 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;