diff --git a/Cargo.lock b/Cargo.lock index 7f23ff6..eb854ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1208,7 +1208,7 @@ checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" [[package]] name = "lexmount-browser" -version = "1.1.15" +version = "1.2.0" dependencies = [ "base64 0.22.1", "clap", diff --git a/Cargo.toml b/Cargo.toml index d485aa4..7e35b51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lexmount-browser" -version = "1.1.15" +version = "1.2.0" edition = "2024" license = "MIT" description = "Native Rust SDK and CLI for Lexmount cloud browsers" diff --git a/README.md b/README.md index 36baa0c..a53fd80 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,65 @@ are never printed. All commands emit one JSON document. Run `browser-cli --help` for the complete surface. +## Select a page in a multi-tab session + +Explicit page selection is introduced in version 1.2.0. Check that the installed +binary's `browser-cli action --help` lists `--target-id`; the published 1.1.15 +binary does not have it. The package version and both bootstrap scripts target +1.2.0 together. Merging or building this source does not publish release assets: +bootstrap can install 1.2.0 only after its binaries and checksums are published +to COS. Until then, use a source build for local verification. + +Every `action` command accepts an optional `--target-id`. Obtain the page's CDP +target ID from `session targets` (the page entry's `id` in a DevTools `/json` +listing), then pass it on **each** action that should use that tab: + +```bash +browser-cli session targets --session-id SESSION_ID +browser-cli action snapshot --session-id SESSION_ID --target-id PAGE_ID +browser-cli action fill --session-id SESSION_ID --target-id PAGE_ID --selector '#query' --value 'search terms' +browser-cli action click --session-id SESSION_ID --target-id PAGE_ID --selector '#search' +# If this opened a new tab, list targets again and select the result page. +browser-cli session targets --session-id SESSION_ID +browser-cli action wait-selector --session-id SESSION_ID --target-id RESULT_PAGE_ID --selector '#results' +browser-cli action snapshot --session-id SESSION_ID --target-id RESULT_PAGE_ID +``` + +The option can also precede the action subcommand: +`browser-cli action --target-id PAGE_ID snapshot --session-id SESSION_ID`. +It applies to all actions, including `open-url`, `screenshot`, `pdf`, and `raw`; +their JSON result shapes are unchanged. It is not a session/context option. + +The browser session ID and page target ID identify different things. An explicit +target must be an existing page in that browser session. A missing/closed target +returns `not_found`; a non-page target returns `configuration_error`. If the page +closes between discovery and attachment, the CDP error is propagated. None of +these cases falls back to another tab or creates a blank page. + +Without `--target-id`, the existing default is unchanged: select the first page +returned by CDP, or create `about:blank` if no page exists. That default is **not** +a guarantee to follow a popup or select the most recently used tab. Explicit +selection is per invocation; there is no persisted active-page state or automatic +new-tab switching. Select by the task's expected URL/title, not list position, +and inspect again when there are multiple plausible pages. + +SDK callers can use `lexmount_browser::cdp::Cdp::connect_to_target(ws_url, page_id)`. +`Cdp::connect(ws_url)` retains its existing default behavior. + +### Local regression tests + +```bash +cargo test --all-targets --locked +# Optional: use a local Chrome/Chromium executable, including chrome-headless-shell. +BROWSER_CLI_TEST_CHROME=/path/to/chrome cargo test --locked --test page_targets_browser -- --ignored --nocapture +``` + +In PowerShell, set `$env:BROWSER_CLI_TEST_CHROME` to the executable path before +running the same `cargo test` command. The opt-in test launches a separate +headless profile and loopback-only fixtures; it does not use a Lexmount account, +real websites, or an existing browser profile. The default suite exercises all +action routes and failure/no-fallback behavior with deterministic CDP fixtures. + ## Agent Skill package The publishable Skill is in `skills/lexmount-browser`. Build a deterministic ZIP: @@ -37,6 +96,14 @@ release from Tencent Cloud COS and verifies its SHA-256 digest. Set `LEXMOUNT_BROWSER_CLI_VERSION` or `LEXMOUNT_BROWSER_CLI_DOWNLOAD_BASE_URL` only when testing a different published release or mirror. +Updating the Skill files does not replace an existing Skill-local executable. +After the pinned release is available, an authorized upgrade can rerun the +matching bundled bootstrap script, then verify `browser-cli version` and +`browser-cli action --help`. If the release is not available, report that +dependency rather than substituting an older binary for a task needing the new +feature. Release tags must match the Cargo and bootstrap versions; never +overwrite an existing release with changed binaries. + Agents resolve bundled scripts and binaries from the directory containing the loaded `SKILL.md`: Codex uses the absolute source path supplied in the Skill metadata, Claude Code uses `${CLAUDE_SKILL_DIR}`, and WorkBuddy/CodeBuddy uses diff --git a/skills/lexmount-browser/SKILL.md b/skills/lexmount-browser/SKILL.md index aa9e797..9980474 100644 --- a/skills/lexmount-browser/SKILL.md +++ b/skills/lexmount-browser/SKILL.md @@ -44,6 +44,18 @@ Read [authentication.md](references/authentication.md) only when login or creden 5. Take screenshots when visual confirmation matters. 6. Close temporary sessions with `browser-cli session close`. A read-write Context saves state on normal session close. +For multi-tab work, first check that `browser-cli action --help` lists +`--target-id`, introduced in 1.2.0. Updating the Skill does not upgrade an existing +binary. If absent, follow the [upgrade guidance](references/commands.md#page-selection) +and report the limitation if an upgrade cannot be completed; default-page actions +are not an equivalent substitute. +With support available, inspect `session targets`, select the page matching the task, +and pass its ID as `--target-id` on each action. After a click opens a new tab, +list targets again and explicitly select that page before waiting or inspecting; +an unchanged source page alone does not mean the click failed. See +[page selection](references/commands.md#page-selection) for discovery, compatibility, +and missing-target handling. Do not infer the active page from list order. + ## Safety - Ask before submitting purchases, publishing content, deleting remote data, or changing account/security settings. diff --git a/skills/lexmount-browser/references/commands.md b/skills/lexmount-browser/references/commands.md index 8cdf799..155eccd 100644 --- a/skills/lexmount-browser/references/commands.md +++ b/skills/lexmount-browser/references/commands.md @@ -42,6 +42,48 @@ browser-cli action eval --session-id ID --expression JS browser-cli action raw --session-id ID --method CDP_METHOD --params-json JSON ``` +## Page selection + +Introduced in 1.2.0; requires a binary whose `browser-cli action --help` lists +`--target-id`. The published 1.1.15 binary does not include this feature. +Check the actual Skill-local binary, not just the version of these instructions. + +For an authorized upgrade, rerun the matching Skill-local bootstrap script only +after its pinned 1.2.0 release assets are available, then verify `version` and +`action --help`. A merged PR or a newer Skill file does not publish or replace +the binary. If the release is unavailable or the upgrade is not authorized, +report the dependency or capability limitation; do not send unsupported flags +or drop the target selection to continue against a different page. + +Every `action` above accepts optional `--target-id PAGE_ID`, before or after the +action subcommand. It selects an existing page inside `--session-id`; it is not +a replacement for the browser session ID. JSON output shapes are unchanged. + +```text +browser-cli session targets --session-id SESSION_ID +browser-cli action snapshot --session-id SESSION_ID --target-id PAGE_ID +browser-cli action click --session-id SESSION_ID --target-id PAGE_ID --selector CSS +browser-cli session targets --session-id SESSION_ID +browser-cli action wait-selector --session-id SESSION_ID --target-id NEW_PAGE_ID --selector CSS +browser-cli action snapshot --session-id SESSION_ID --target-id NEW_PAGE_ID +``` + +Use the page entry's `id` in the DevTools target listing (`targetId` when using +CDP `Target.getTargets` directly). Match the expected URL/title and page type, +not the first/last position or an attached CDP `sessionId`. A popup may take time +to appear or navigate: refresh the listing within a bounded task timeout, then +wait for the required selector/text on the selected page. If several pages are +plausible, inspect them before choosing; do not blindly retry a state-changing +click. Pass the chosen ID on each subsequent action; selection is not persisted. + +Without this option, the CLI keeps its original default: attach to the first +page returned by CDP, or create `about:blank` if no page exists. It does not +automatically follow a newly opened tab. A missing/closed explicit target fails +with `not_found`; a non-page target fails with `configuration_error`. A target +closed during attachment can produce `cdp_error`. The CLI never falls back to +another page or creates a page when an explicit target cannot be used. Re-list +targets and reassess the task instead of dropping `--target-id` to bypass errors. + Use temporary sessions for public browsing. Use a dedicated persistent Context per account or purpose; avoid sharing one read-write Context between parallel tasks. `wait-text` uses case-insensitive normalized contains matching by default. Add diff --git a/skills/lexmount-browser/references/troubleshooting.md b/skills/lexmount-browser/references/troubleshooting.md index 0c6921b..d247c73 100644 --- a/skills/lexmount-browser/references/troubleshooting.md +++ b/skills/lexmount-browser/references/troubleshooting.md @@ -2,11 +2,13 @@ Run `browser-cli doctor` first and use the failed check's message. -- `configuration_error`: run `browser-cli auth login`, or verify the managed environment contains both required variables. +- `configuration_error`: read the message first. For an empty or non-page `--target-id`, inspect `session targets` and select a page; re-authentication will not fix page selection. For missing credentials, run `browser-cli auth login`, or verify the managed environment contains both required variables. +- `not_found` for a page target: it may have closed or belong to another browser session. Re-list `session targets` for the intended session and choose the correct page. Do not remove `--target-id` and accidentally retry against the default page. - `authentication_error`: the credential was rejected; log out and authorize again. - `conflict`: a read-write Context is already locked. Use another Context, wait for the active session, or use read-only mode. Force-release only after confirming the session is dead. - `timeout`: inspect session status and network access, then retry with a larger timeout. -- `cdp_error`: verify the session is active, inspect `session targets`, and take a snapshot before retrying the action. +- `cdp_error`: verify the session is active and inspect `session targets`; a page can disappear between listing and attachment. Take a snapshot of the explicitly selected page before deciding whether to retry the action. +- Source URL unchanged after a click: the click may have opened a new tab. Inspect `session targets` and use `--target-id` for the intended new page; do not assume the click failed or that subsequent commands automatically follow it. See [page selection](commands.md#page-selection). - Skill root unknown: resolve the directory containing the loaded `SKILL.md` with the current host's locator: Codex supplies its absolute source path in the Skill metadata, Claude Code provides `${CLAUDE_SKILL_DIR}`, and WorkBuddy/CodeBuddy provides `${CODEBUDDY_SKILL_DIR}`. Do not infer it from the working directory or search the user's home directory. - command not found after bootstrap: invoke `"/bin/browser-cli"` on macOS arm64 or `& "\bin\browser-cli.exe"` in Windows PowerShell; no PATH change or restart is required. diff --git a/skills/lexmount-browser/scripts/bootstrap.ps1 b/skills/lexmount-browser/scripts/bootstrap.ps1 index d676619..2badbd2 100644 --- a/skills/lexmount-browser/scripts/bootstrap.ps1 +++ b/skills/lexmount-browser/scripts/bootstrap.ps1 @@ -12,7 +12,7 @@ function Invoke-Tls12Download { } } -$version = if ($env:LEXMOUNT_BROWSER_CLI_VERSION) { $env:LEXMOUNT_BROWSER_CLI_VERSION } else { "1.1.15" } +$version = if ($env:LEXMOUNT_BROWSER_CLI_VERSION) { $env:LEXMOUNT_BROWSER_CLI_VERSION } else { "1.2.0" } $downloadBaseUrl = if ($env:LEXMOUNT_BROWSER_CLI_DOWNLOAD_BASE_URL) { $env:LEXMOUNT_BROWSER_CLI_DOWNLOAD_BASE_URL.TrimEnd('/') } else { "https://cli-bin-1377899528.cos.ap-nanjing.myqcloud.com/releases/browser-cli" } $architecture = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } if ($architecture -ne "AMD64") { throw "Only Windows x64 is supported" } diff --git a/skills/lexmount-browser/scripts/bootstrap.sh b/skills/lexmount-browser/scripts/bootstrap.sh index fce1662..f016089 100755 --- a/skills/lexmount-browser/scripts/bootstrap.sh +++ b/skills/lexmount-browser/scripts/bootstrap.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu -version="${LEXMOUNT_BROWSER_CLI_VERSION:-1.1.15}" +version="${LEXMOUNT_BROWSER_CLI_VERSION:-1.2.0}" download_base_url="${LEXMOUNT_BROWSER_CLI_DOWNLOAD_BASE_URL:-https://cli-bin-1377899528.cos.ap-nanjing.myqcloud.com/releases/browser-cli}" repo="${download_base_url%/}/v${version}" case "$(uname -s)-$(uname -m)" in diff --git a/src/cdp.rs b/src/cdp.rs index c530d23..6ff97e0 100644 --- a/src/cdp.rs +++ b/src/cdp.rs @@ -31,7 +31,26 @@ pub struct WaitTextOptions<'a> { } impl Cdp { + /// Connect to the first page, creating a blank page if necessary. + /// + /// This preserves the original single-page default. Use `connect_to_target` + /// to address a particular tab without depending on target enumeration order. pub fn connect(url: &str) -> Result { + Self::connect_with_target(url, None) + } + + /// Connect to an existing page in this browser session. + /// + /// Missing, closed, or non-page targets return an error; they never fall back + /// to another page or cause a blank page to be created. + pub fn connect_to_target(url: &str, target_id: &str) -> Result { + if target_id.trim().is_empty() { + return Err(Error::Config("target ID must not be empty".into())); + } + Self::connect_with_target(url, Some(target_id)) + } + + fn connect_with_target(url: &str, requested_target: Option<&str>) -> Result { let (socket, _) = tungstenite::connect(url)?; let mut client = Self { socket, @@ -40,17 +59,7 @@ impl Cdp { events: VecDeque::new(), }; let targets = client.command_root("Target.getTargets", json!({}))?; - let target_id = targets - .get("targetInfos") - .and_then(Value::as_array) - .and_then(|items| { - items - .iter() - .find(|v| v.get("type").and_then(Value::as_str) == Some("page")) - }) - .and_then(|v| v.get("targetId")) - .and_then(Value::as_str) - .map(str::to_owned); + let target_id = select_page_target(&targets, requested_target)?; let target_id = match target_id { Some(id) => id, None => client @@ -280,6 +289,31 @@ impl Cdp { } } +fn select_page_target(targets: &Value, requested: Option<&str>) -> Result> { + let pages = targets.get("targetInfos").and_then(Value::as_array); + if let Some(id) = requested { + let pages = pages + .ok_or_else(|| Error::Cdp("Target.getTargets response missing targetInfos".into()))?; + let target = pages + .iter() + .find(|item| item["targetId"].as_str() == Some(id)); + match target { + Some(item) if item["type"] == "page" => Ok(Some(id.to_owned())), + Some(_) => Err(Error::Config(format!( + "target {id} is not a page; inspect `session targets` and select a page target" + ))), + None => Err(Error::NotFound(format!( + "page target {id} is not available in this session; inspect `session targets`" + ))), + } + } else { + Ok(pages + .and_then(|items| items.iter().find(|item| item["type"] == "page")) + .and_then(|item| item["targetId"].as_str()) + .map(str::to_owned)) + } +} + fn text_matches(candidate: &str, query: &str, exact: bool, case_sensitive: bool) -> bool { let normalize = |value: &str| value.split_whitespace().collect::>().join(" "); let mut haystack = normalize(candidate); @@ -297,7 +331,81 @@ fn text_matches(candidate: &str, query: &str, exact: bool, case_sensitive: bool) #[cfg(test)] mod tests { - use super::text_matches; + use super::{select_page_target, text_matches}; + use crate::Error; + use serde_json::json; + + #[test] + fn explicit_page_selection_does_not_depend_on_order() { + let home = json!({"type":"page","targetId":"home"}); + let result = json!({"type":"page","targetId":"result"}); + for items in [vec![home.clone(), result.clone()], vec![result, home]] { + let targets = json!({"targetInfos":items}); + assert_eq!( + select_page_target(&targets, Some("result")).unwrap(), + Some("result".into()) + ); + } + } + + #[test] + fn missing_or_non_page_target_never_falls_back() { + let targets = json!({"targetInfos":[ + {"type":"service_worker","targetId":"worker"}, + {"type":"page","targetId":"home"} + ]}); + assert!(matches!( + select_page_target(&targets, Some("closed")), + Err(Error::NotFound(_)) + )); + assert!(matches!( + select_page_target(&targets, Some("worker")), + Err(Error::Config(_)) + )); + assert!(matches!( + select_page_target(&json!({"targetInfos":[]}), Some("missing")), + Err(Error::NotFound(_)) + )); + } + + #[test] + fn unspecified_target_preserves_first_page_and_empty_browser_defaults() { + let targets = json!({"targetInfos":[ + {"type":"service_worker","targetId":"worker"}, + {"type":"page","targetId":"home"}, + {"type":"page","targetId":"result"} + ]}); + assert_eq!( + select_page_target(&targets, None).unwrap(), + Some("home".into()) + ); + assert_eq!( + select_page_target(&json!({"targetInfos":[]}), None).unwrap(), + None + ); + } + + #[test] + fn malformed_explicit_target_listing_is_rejected() { + assert!(matches!( + select_page_target(&json!({}), Some("page")), + Err(Error::Cdp(_)) + )); + assert!(matches!( + select_page_target(&json!({"targetInfos":null}), Some("page")), + Err(Error::Cdp(_)) + )); + } + + #[test] + fn empty_explicit_target_is_rejected_before_connecting() { + for id in ["", " "] { + assert!(matches!( + super::Cdp::connect_to_target("not-a-websocket", id), + Err(Error::Config(_)) + )); + } + } #[test] fn wait_text_defaults_to_case_insensitive_contains() { diff --git a/src/main.rs b/src/main.rs index ceaafa7..77042cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,9 @@ enum Command { command: ContextCommand, }, Action { + /// Operate on this page target from `session targets`, not the default page. + #[arg(long, global = true)] + target_id: Option, #[command(subcommand)] command: ActionCommand, }, @@ -306,7 +309,9 @@ fn run(cli: Cli) -> Result { Command::Doctor => doctor(), Command::Session { command } => run_session(Client::from_env()?, command), Command::Context { command } => run_context(Client::from_env()?, command), - Command::Action { command } => run_action(Client::from_env()?, command), + Command::Action { command, target_id } => { + run_action(Client::from_env()?, command, target_id.as_deref()) + } } } @@ -475,7 +480,7 @@ fn run_context(client: Client, command: ContextCommand) -> Result { } } -fn run_action(client: Client, command: ActionCommand) -> Result { +fn run_action(client: Client, command: ActionCommand, target_id: Option<&str>) -> Result { let session_id = match &command { ActionCommand::OpenUrl { session_id, .. } | ActionCommand::Eval { session_id, .. } @@ -492,7 +497,10 @@ fn run_action(client: Client, command: ActionCommand) -> Result { let ws = session .ws .ok_or_else(|| Error::Cdp(format!("session {session_id} has no CDP WebSocket URL")))?; - let mut cdp = Cdp::connect(&ws)?; + let mut cdp = match target_id { + Some(id) => Cdp::connect_to_target(&ws, id)?, + None => Cdp::connect(&ws)?, + }; match command { ActionCommand::OpenUrl { url, timeout_ms, .. @@ -576,6 +584,73 @@ fn print_json(value: &T) { mod tests { use super::*; + #[test] + fn page_target_flag_is_accepted_before_or_after_action_subcommand() { + for args in [ + vec![ + "browser-cli", + "action", + "--target-id", + "result", + "eval", + "--session-id", + "browser", + "--expression", + "location.href", + ], + vec![ + "browser-cli", + "action", + "eval", + "--session-id", + "browser", + "--expression", + "location.href", + "--target-id", + "result", + ], + ] { + let Command::Action { target_id, .. } = Cli::try_parse_from(args).unwrap().command + else { + panic!("expected action"); + }; + assert_eq!(target_id.as_deref(), Some("result")); + } + } + + #[test] + fn existing_action_syntax_has_no_explicit_target() { + let Command::Action { target_id, .. } = Cli::try_parse_from([ + "browser-cli", + "action", + "snapshot", + "--session-id", + "browser", + ]) + .unwrap() + .command + else { + panic!("expected action"); + }; + assert!(target_id.is_none()); + } + + #[test] + fn page_target_option_is_not_accepted_by_session_commands() { + assert!( + Cli::try_parse_from([ + "browser-cli", + "session", + "targets", + "--session-id", + "browser", + "--target-id", + "result" + ]) + .is_err() + ); + } + #[test] fn auth_login_defaults_client_name_to_agent() { let cli = Cli::try_parse_from(["browser-cli", "auth", "login"]).unwrap(); diff --git a/tests/page_targets.rs b/tests/page_targets.rs new file mode 100644 index 0000000..3cc8cd3 --- /dev/null +++ b/tests/page_targets.rs @@ -0,0 +1,342 @@ +//! Deterministic protocol and CLI tests. Only local HTTP/WebSocket fixtures run. +mod support; + +use httpmock::{Method::POST, MockServer}; +use lexmount_browser::cdp::Cdp; +use serde_json::{Value, json}; +use std::{ + fs, + io::ErrorKind, + net::TcpListener, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; +use tungstenite::{Message, error::ProtocolError}; + +struct Peer { + url: String, + worker: JoinHandle>, +} + +impl Peer { + fn start(targets: Value, fail_attach: bool) -> Self { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let worker = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(10); + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(e) if e.kind() == ErrorKind::WouldBlock && Instant::now() < deadline => { + thread::sleep(Duration::from_millis(10)); + } + Err(e) => panic!("fixture accept: {e}"), + } + }; + // Windows inherits the listener's nonblocking mode on accept. + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut socket = tungstenite::accept(stream).unwrap(); + let mut seen = Vec::new(); + let mut attached = String::new(); + loop { + let raw = match socket.read() { + Ok(Message::Text(text)) => text, + Ok(Message::Close(_)) => break, + Ok(_) => continue, + Err( + tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed, + ) + | Err(tungstenite::Error::Protocol( + ProtocolError::ResetWithoutClosingHandshake, + )) => break, + Err(tungstenite::Error::Io(e)) + if matches!( + e.kind(), + ErrorKind::ConnectionReset | ErrorKind::UnexpectedEof + ) => + { + break; + } + Err(e) => panic!("fixture read: {e}"), + }; + let request: Value = serde_json::from_str(&raw).unwrap(); + seen.push(request.clone()); + let method = request["method"].as_str().unwrap(); + let result = match method { + "Target.getTargets" => json!({"targetInfos":targets}), + "Target.createTarget" => { + assert_eq!(request["params"], json!({"url":"about:blank"})); + json!({"targetId":"created"}) + } + "Target.attachToTarget" => { + assert_eq!(request["params"]["flatten"], true); + if fail_attach { + socket + .send(Message::Text( + json!({"id":request["id"],"error":{ + "code":-32602,"message":"No target with given id found" + }}) + .to_string() + .into(), + )) + .unwrap(); + continue; + } + attached = request["params"]["targetId"].as_str().unwrap().to_owned(); + json!({"sessionId":format!("attached-{attached}")}) + } + _ => { + assert!(!attached.is_empty()); + assert_eq!(request["sessionId"], format!("attached-{attached}")); + match method { + "Page.enable" | "Runtime.enable" => json!({}), + "Page.navigate" => json!({"frameId":"frame"}), + "Page.getFrameTree" => json!({"selectedTarget":attached}), + "Page.getLayoutMetrics" => { + json!({"cssContentSize":{"width":10,"height":10}}) + } + "Page.captureScreenshot" | "Page.printToPDF" => { + json!({"data":"YXJ0aWZhY3Q="}) + } + "Runtime.evaluate" => { + let expression = request["params"]["expression"].as_str().unwrap(); + let value = match expression { + "document.readyState" => json!("complete"), + "location.href" => { + json!(format!("https://example.test/{attached}")) + } + "document.title" => json!(attached), + value if value.starts_with("(()=>({url:") => { + json!({"url":format!("https://example.test/{attached}")}) + } + value if value.contains("const visible=") => { + json!([{"text":"Saved"}]) + } + _ => json!(true), + }; + json!({"result":{"value":value}}) + } + _ => panic!("unexpected method: {method}"), + } + } + }; + socket + .send(Message::Text( + json!({"id":request["id"],"result":result}) + .to_string() + .into(), + )) + .unwrap(); + } + seen + }); + Self { url, worker } + } + + fn finish(self) -> Vec { + self.worker.join().unwrap() + } +} + +fn api(websocket: &str) -> MockServer { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST) + .path("/instance/session") + .json_body_partial(r#"{"session_id":"browser"}"#); + then.status(200) + .json_body(json!({"session_id":"browser","status":"active","ws":websocket})); + }); + server +} + +fn two_pages() -> Value { + json!([ + {"targetId":"home","type":"page"}, + {"targetId":"wanted","type":"page"}, + {"targetId":"worker","type":"service_worker"} + ]) +} + +#[test] +fn sdk_selects_the_requested_target_regardless_of_enumeration_order() { + for reversed in [false, true] { + let mut targets = two_pages(); + if reversed { + targets.as_array_mut().unwrap().reverse(); + } + let peer = Peer::start(targets, false); + { + let mut cdp = Cdp::connect_to_target(&peer.url, "wanted").unwrap(); + assert_eq!( + cdp.evaluate("location.href").unwrap(), + "https://example.test/wanted" + ); + } + let seen = peer.finish(); + assert_eq!(seen[1]["params"]["targetId"], "wanted"); + assert!(seen.iter().all(|v| v["method"] != "Target.createTarget")); + } +} + +#[test] +fn every_cli_action_routes_to_the_explicit_target_and_preserves_json_output() { + let directory = tempfile::tempdir().unwrap(); + for arguments in [ + vec!["open-url", "--url", "https://example.test/wanted"], + vec!["eval", "--expression", "location.href"], + vec!["wait-selector", "--selector", "#ready"], + vec!["wait-text", "--text", "Saved"], + vec!["click", "--selector", "#button"], + vec!["fill", "--selector", "#input", "--value", "hello"], + vec!["screenshot", "--path", "image.png", "--full-page"], + vec!["pdf", "--path", "page.pdf", "--print-background"], + vec!["snapshot"], + vec!["raw", "--method", "Page.getFrameTree"], + ] { + let peer = Peer::start(two_pages(), false); + let server = api(&peer.url); + let mut args = vec!["action"]; + args.extend(&arguments); + args.extend(["--session-id", "browser", "--target-id", "wanted"]); + let result = support::data(support::cli(&server.base_url(), directory.path(), &args)); + match arguments[0] { + "open-url" | "snapshot" => assert_eq!(result["url"], "https://example.test/wanted"), + "eval" => assert_eq!(result, "https://example.test/wanted"), + "wait-selector" | "wait-text" => assert_eq!(result["found"], true), + "click" | "fill" => assert_eq!(result, true), + "screenshot" | "pdf" => { + assert_eq!(result["bytes"], 8); + assert_eq!( + fs::read(directory.path().join(arguments[2])).unwrap(), + b"artifact" + ); + } + "raw" => assert_eq!(result["selectedTarget"], "wanted"), + _ => unreachable!(), + } + let seen = peer.finish(); + assert_eq!(seen[1]["params"]["targetId"], "wanted"); + assert!(seen.iter().all(|v| v["method"] != "Target.createTarget")); + assert!( + seen.iter() + .skip(2) + .all(|v| v["sessionId"] == "attached-wanted") + ); + } +} + +#[test] +fn invalid_cli_targets_fail_without_attaching_creating_or_overwriting_files() { + for (targets, id, error) in [ + (two_pages(), "closed-or-other-session", "not_found"), + (json!([]), "missing", "not_found"), + (two_pages(), "worker", "configuration_error"), + ] { + let peer = Peer::start(targets, false); + let server = api(&peer.url); + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("existing.png"), b"do not replace").unwrap(); + let output = support::cli( + &server.base_url(), + directory.path(), + &[ + "action", + "screenshot", + "--session-id", + "browser", + "--target-id", + id, + "--path", + "existing.png", + ], + ); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + let result: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(result["ok"], false); + assert_eq!(result["error"], error); + assert!( + result["message"] + .as_str() + .unwrap() + .contains("session targets") + ); + assert_eq!( + fs::read(directory.path().join("existing.png")).unwrap(), + b"do not replace" + ); + let seen = peer.finish(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0]["method"], "Target.getTargets"); + } +} + +#[test] +fn target_closed_between_listing_and_attach_fails_without_fallback() { + let peer = Peer::start(two_pages(), true); + let server = api(&peer.url); + let directory = tempfile::tempdir().unwrap(); + let output = support::cli( + &server.base_url(), + directory.path(), + &[ + "action", + "click", + "--session-id", + "browser", + "--target-id", + "wanted", + "--selector", + "#button", + ], + ); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + let result: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(result["error"], "cdp_error"); + let seen = peer.finish(); + assert_eq!(seen.len(), 2); + assert_eq!(seen[1]["method"], "Target.attachToTarget"); + assert_eq!(seen[1]["params"]["targetId"], "wanted"); +} + +#[test] +fn default_cli_still_uses_first_page_or_creates_blank_when_no_pages_exist() { + for (targets, selected, creates) in [(two_pages(), "home", false), (json!([]), "created", true)] + { + let peer = Peer::start(targets, false); + let server = api(&peer.url); + let directory = tempfile::tempdir().unwrap(); + let result = support::data(support::cli( + &server.base_url(), + directory.path(), + &[ + "action", + "eval", + "--session-id", + "browser", + "--expression", + "location.href", + ], + )); + assert_eq!(result, format!("https://example.test/{selected}")); + let seen = peer.finish(); + assert_eq!( + seen.iter().any(|v| v["method"] == "Target.createTarget"), + creates + ); + let attached = seen + .iter() + .find(|v| v["method"] == "Target.attachToTarget") + .unwrap(); + assert_eq!(attached["params"]["targetId"], selected); + } +} diff --git a/tests/page_targets_browser.rs b/tests/page_targets_browser.rs new file mode 100644 index 0000000..5d84e35 --- /dev/null +++ b/tests/page_targets_browser.rs @@ -0,0 +1,363 @@ +//! Opt-in end-to-end regression with a separate headless Chrome profile. +//! No GUI, cloud session, real site, or user credentials are used. +mod support; + +use httpmock::{Method::GET, Method::POST, MockServer}; +use lexmount_browser::cdp::Cdp; +use serde_json::{Value, json}; +use std::{ + fs, + path::Path, + process::{Child, Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +const HOME: &str = r#"Search fixture + +"#; + +const RESULT: &str = r#"Result fixture +

Search result

+"#; + +struct Browser { + process: Child, + _profile: tempfile::TempDir, + websocket: String, + http: String, +} + +impl Browser { + fn start(executable: &Path) -> Self { + let profile = tempfile::Builder::new() + .prefix("browser-cli-targets-") + .tempdir() + .unwrap(); + let mut command = Command::new(executable); + command.args([ + "--headless", + "--remote-debugging-port=0", + "--remote-debugging-address=127.0.0.1", + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-component-update", + "--disable-sync", + "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1", + "--no-proxy-server", + ]); + command.arg(format!("--user-data-dir={}", profile.path().display())); + command.arg("about:blank"); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + let process = command.spawn().unwrap(); + let mut browser = Self { + process, + _profile: profile, + websocket: String::new(), + http: String::new(), + }; + let deadline = Instant::now() + Duration::from_secs(20); + loop { + if let Ok(port_file) = + fs::read_to_string(browser._profile.path().join("DevToolsActivePort")) + { + let mut lines = port_file.lines(); + let port: u16 = lines.next().unwrap().parse().unwrap(); + let path = lines.next().unwrap(); + assert!(path.starts_with("/devtools/browser/")); + browser.websocket = format!("ws://127.0.0.1:{port}{path}"); + browser.http = format!("http://127.0.0.1:{port}"); + return browser; + } + assert!( + browser.process.try_wait().unwrap().is_none(), + "headless browser exited" + ); + assert!(Instant::now() < deadline, "headless startup timed out"); + thread::sleep(Duration::from_millis(50)); + } + } +} + +impl Drop for Browser { + fn drop(&mut self) { + // Close only this test's isolated browser, including its renderer children. + if !self.websocket.is_empty() + && let Ok((mut socket, _)) = tungstenite::connect(self.websocket.as_str()) + { + let _ = socket.send(tungstenite::Message::Text( + json!({"id":1,"method":"Browser.close"}).to_string().into(), + )); + } + let deadline = Instant::now() + Duration::from_secs(5); + while self.process.try_wait().ok().flatten().is_none() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(50)); + } + let _ = self.process.kill(); + let _ = self.process.wait(); + } +} + +fn pages(cdp: &mut Cdp) -> Vec { + cdp.command_root("Target.getTargets", json!({})).unwrap()["targetInfos"] + .as_array() + .unwrap() + .iter() + .filter(|v| v["type"] == "page") + .cloned() + .collect() +} + +#[test] +#[ignore = "requires BROWSER_CLI_TEST_CHROME pointing to a Chrome/Chromium executable"] +fn search_popup_can_be_selected_across_cli_invocations_and_closed_safely() { + let chromium = std::env::var_os("BROWSER_CLI_TEST_CHROME") + .expect("set BROWSER_CLI_TEST_CHROME to a local Chrome/Chromium executable"); + let browser = Browser::start(Path::new(&chromium)); + let directory = tempfile::tempdir().unwrap(); + let fixture = MockServer::start(); + fixture.mock(|when, then| { + when.method(GET).path("/home"); + then.status(200) + .header("Content-Type", "text/html; charset=utf-8") + .body(HOME); + }); + fixture.mock(|when, then| { + when.method(GET).path("/result"); + then.status(200) + .header("Content-Type", "text/html; charset=utf-8") + .body(RESULT); + }); + let api = MockServer::start(); + api.mock(|when, then| { + when.method(POST).path("/instance/session"); + then.status(200) + .json_body(json!({"session_id":"browser","status":"active","ws":browser.websocket})); + }); + let run = |args: &[&str]| support::data(support::cli(&api.base_url(), directory.path(), args)); + let mut observer = Cdp::connect(&browser.websocket).unwrap(); + let version = observer + .command_root("Browser.getVersion", json!({})) + .unwrap(); + let initial = pages(&mut observer); + assert_eq!(initial.len(), 1); + let home_id = initial[0]["targetId"].as_str().unwrap(); + let home_url = fixture.url("/home"); + // Legacy syntax still works for the initial single page. + assert_eq!( + run(&[ + "action", + "open-url", + "--session-id", + "browser", + "--url", + &home_url + ])["url"], + home_url + ); + run(&[ + "action", + "wait-selector", + "--session-id", + "browser", + "--target-id", + home_id, + "--selector", + "#query", + ]); + run(&[ + "action", + "fill", + "--session-id", + "browser", + "--target-id", + home_id, + "--selector", + "#query", + "--value", + "page target", + ]); + run(&[ + "action", + "click", + "--session-id", + "browser", + "--target-id", + home_id, + "--selector", + "#search", + ]); + let deadline = Instant::now() + Duration::from_secs(10); + let result = loop { + let targets = pages(&mut observer); + if let Some(result) = targets.iter().find(|v| { + v["url"] + .as_str() + .is_some_and(|url| url.starts_with(&fixture.url("/result"))) + }) { + break result.clone(); + } + assert!(Instant::now() < deadline, "search popup did not open"); + thread::sleep(Duration::from_millis(50)); + }; + assert_eq!(result["openerId"], home_id); + let result_id = result["targetId"].as_str().unwrap(); + let result_url = result["url"].as_str().unwrap(); + + // Reuse the real browser's /json listing as the local session-targets API. + // This proves its page `id` is usable as --target-id, not a CDP sessionId. + let listed: Value = reqwest::blocking::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .unwrap() + .get(format!("{}/json", browser.http)) + .send() + .unwrap() + .json() + .unwrap(); + api.mock(|when, then| { + when.method(GET) + .path("/json") + .query_param("session_id", "browser"); + then.status(200).json_body(listed.clone()); + }); + let discovered = run(&["session", "targets", "--session-id", "browser"]); + let selected = discovered + .as_array() + .unwrap() + .iter() + .find(|v| v["type"] == "page" && v["url"] == result_url) + .unwrap(); + assert_eq!(selected["id"], result_id); + let selected_id = selected["id"].as_str().unwrap(); + run(&[ + "action", + "wait-selector", + "--session-id", + "browser", + "--target-id", + selected_id, + "--selector", + "#result", + ]); + for _ in 0..3 { + assert_eq!( + run(&[ + "action", + "eval", + "--session-id", + "browser", + "--target-id", + selected_id, + "--expression", + "location.href" + ]), + result_url + ); + } + // An unrelated tab's lifecycle cannot change explicit action routing. + let extra = observer + .command_root("Target.createTarget", json!({"url":"about:blank"})) + .unwrap(); + observer + .command_root("Target.closeTarget", json!({"targetId":extra["targetId"]})) + .unwrap(); + run(&[ + "action", + "fill", + "--session-id", + "browser", + "--target-id", + selected_id, + "--selector", + "#query", + "--value", + "saved on result", + ]); + run(&[ + "action", + "click", + "--session-id", + "browser", + "--target-id", + selected_id, + "--selector", + "#save", + ]); + let snapshot = run(&[ + "action", + "snapshot", + "--session-id", + "browser", + "--target-id", + selected_id, + ]); + assert_eq!(snapshot["url"], result_url); + assert!( + snapshot["text"] + .as_str() + .unwrap() + .contains("saved on result") + ); + assert_eq!( + observer + .evaluate("document.querySelector('#query').value") + .unwrap(), + "page target" + ); + assert_eq!(observer.evaluate("location.href").unwrap(), home_url); + + observer + .command_root("Target.closeTarget", json!({"targetId":selected_id})) + .unwrap(); + let output = support::cli( + &api.base_url(), + directory.path(), + &[ + "action", + "fill", + "--session-id", + "browser", + "--target-id", + selected_id, + "--selector", + "#query", + "--value", + "must not reach home", + ], + ); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + let error: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(error["error"], "not_found"); + assert_eq!( + observer + .evaluate("document.querySelector('#query').value") + .unwrap(), + "page target" + ); + let remaining = pages(&mut observer); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0]["targetId"], home_id); + println!( + "{}", + json!({"browser":version["product"],"protocol":version["protocolVersion"], + "legacy_single_page":"passed","popup_discovery":"passed","explicit_reconnects":3, + "unrelated_tab_lifecycle":"passed","result_page_mutation":"passed", + "closed_target_no_fallback":"passed","source_page_unchanged":"passed"}) + ); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 0000000..4b8276a --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,66 @@ +use serde_json::Value; +use std::{ + path::Path, + process::{Command, Output, Stdio}, + thread, + time::{Duration, Instant}, +}; + +// Run the real binary against a loopback fixture, never the user's credentials. +pub fn cli(api: &str, directory: &Path, arguments: &[&str]) -> Output { + assert!(api.starts_with("http://127.0.0.1:")); + let mut command = Command::new(env!("CARGO_BIN_EXE_browser-cli")); + command + .args(arguments) + .current_dir(directory) + .env("LEXMOUNT_API_KEY", "local-test-key") + .env("LEXMOUNT_PROJECT_ID", "local-test-project") + .env("LEXMOUNT_BASE_URL", api) + .env( + "LEXMOUNT_BROWSER_CREDENTIALS_FILE", + directory.join("missing-credentials.json"), + ) + .env("NO_PROXY", "127.0.0.1,localhost") + .env_remove("LEXMOUNT_REGION") + .env_remove("HTTP_PROXY") + .env_remove("HTTPS_PROXY") + .env_remove("ALL_PROXY") + .env_remove("http_proxy") + .env_remove("https_proxy") + .env_remove("all_proxy") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); // CREATE_NO_WINDOW + } + let mut child = command.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + let _ = child.kill(); + let output = child.wait_with_output().unwrap(); + panic!( + "fixture CLI timed out: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + thread::sleep(Duration::from_millis(10)); + } + child.wait_with_output().unwrap() +} + +pub fn data(output: Output) -> Value { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stderr.is_empty()); + let envelope: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(envelope["ok"], true); + assert_eq!(envelope.as_object().unwrap().len(), 2); + envelope["data"].clone() +}