diff --git a/README.md b/README.md index 95720f6..67881a3 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,30 @@ are never printed. All commands emit one JSON document. Run `browser-cli --help` for the complete surface. +### Errors and output pipes + +Successful commands write `{"ok":true,"data":...}` to stdout. Runtime failures +write `{"ok":false,"error":"...","message":"..."}` to stderr and exit with +status 1. JavaScript evaluation failures keep the `cdp_error` category, but now +include the browser's error summary and, when provided, one-based line/column +positions. For example, a missing selector reports `Error: selector not found` +instead of only `Uncaught`. This also applies to actions implemented with +evaluation, such as `click` and `fill`; it does not retry or fix the action. + +The summary is the first line of the exception description (up to 1024 Unicode +characters plus a truncation marker), falling back to a primitive thrown value +or CDP's error text when needed. The CLI does not append the stack trace, +source URL, evaluated expression or remote object preview. Exception messages +are page-provided text and may themselves contain sensitive data; inspect them +before sharing logs. + +If a stdout consumer closes its pipe early (for example, `... | head`), an +otherwise successful command exits normally without a BrokenPipe panic. +Other output write/flush errors still exit with status 1. An operation that +failed still exits with status 1 even if stderr is closed and cannot report the +error. These source changes require a new binary; updating Skill instructions +does not change an already installed CLI. + ## Cloud runtime proxies Version 1.2.1 routes CDP WebSocket connections through the environment's HTTP diff --git a/src/cdp.rs b/src/cdp.rs index deb4bad..aab8980 100644 --- a/src/cdp.rs +++ b/src/cdp.rs @@ -153,13 +153,7 @@ impl Cdp { json!({"expression": expression, "awaitPromise": true, "returnByValue": true}), )?; if let Some(exception) = result.get("exceptionDetails") { - return Err(Error::Cdp( - exception - .get("text") - .and_then(Value::as_str) - .unwrap_or("JavaScript evaluation failed") - .into(), - )); + return Err(Error::Cdp(javascript_exception_message(exception))); } Ok(result .get("result") @@ -290,6 +284,43 @@ impl Cdp { } } +fn javascript_exception_message(details: &Value) -> String { + // Keep the error's first line, not the stack, source URL, expression or + // remote object's preview. The message itself is page-provided text. + fn summary(value: &Value) -> Option { + let line = value.as_str()?.lines().next()?.trim(); + if line.is_empty() { + return None; + } + Some(match line.char_indices().nth(1024) { + Some((end, _)) => format!("{}…", &line[..end]), + None => line.to_owned(), + }) + } + + let exception = &details["exception"]; + let mut message = summary(&exception["description"]) + .or_else(|| match exception.get("value")? { + value @ Value::String(_) => summary(value), + value @ (Value::Null | Value::Bool(_) | Value::Number(_)) => Some(value.to_string()), + _ => None, + }) + .or_else(|| summary(&exception["unserializableValue"])) + .or_else(|| summary(&details["text"])) + .or_else(|| summary(&exception["className"])) + .unwrap_or_else(|| "JavaScript evaluation failed".into()); + + // CDP locations are zero-based; display one-based positions to users. + let position = |key: &str| details[key].as_u64().and_then(|n| n.checked_add(1)); + match (position("lineNumber"), position("columnNumber")) { + (Some(line), Some(column)) => message.push_str(&format!(" (line {line}, column {column})")), + (Some(line), None) => message.push_str(&format!(" (line {line})")), + (None, Some(column)) => message.push_str(&format!(" (column {column})")), + (None, None) => {} + } + message +} + 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 { @@ -332,10 +363,105 @@ fn text_matches(candidate: &str, query: &str, exact: bool, case_sensitive: bool) #[cfg(test)] mod tests { - use super::{select_page_target, text_matches}; + use super::{javascript_exception_message, select_page_target, text_matches}; use crate::Error; use serde_json::json; + #[test] + fn exception_summary_prefers_description_and_omits_stack_and_remote_details() { + let details = json!({ + "text":"Uncaught", "lineNumber":0, "columnNumber":4, + "url":"private-source-url", "scriptId":"private-script-id", + "exception":{ + "description":"TypeError: missing element\n at private-stack-url:1:5", + "objectId":"private-object-id", "preview":{"properties":[{"value":"private-property"}]} + }, + "stackTrace":{"callFrames":[{"url":"private-frame-url"}]} + }); + assert_eq!( + javascript_exception_message(&details), + "TypeError: missing element (line 1, column 5)" + ); + } + + #[test] + fn exception_summary_handles_primitive_throws_and_incomplete_details() { + for (details, expected) in [ + ( + json!({"text":"Uncaught","exception":{"value":"not ready\nsecond line"}}), + "not ready", + ), + (json!({"text":"Uncaught","exception":{"value":42}}), "42"), + ( + json!({"text":"Uncaught","exception":{"value":false}}), + "false", + ), + ( + json!({"text":"Uncaught","exception":{"value":null}}), + "null", + ), + ( + json!({"text":"Uncaught","exception":{"unserializableValue":"99n"}}), + "99n", + ), + ( + json!({"text":"Script execution interrupted"}), + "Script execution interrupted", + ), + ( + json!({"text":"Uncaught","exception":{"value":{"private":"do not include"}}}), + "Uncaught", + ), + ( + json!({"text":" ","exception":{"description":"\nprivate-frame", "className":"Error"}}), + "Error", + ), + (json!({}), "JavaScript evaluation failed"), + (json!(null), "JavaScript evaluation failed"), + ] { + assert_eq!(javascript_exception_message(&details), expected); + } + } + + #[test] + fn exception_locations_are_optional_and_safe_for_invalid_numbers() { + for (details, expected) in [ + ( + json!({"text":"Uncaught","lineNumber":0}), + "Uncaught (line 1)", + ), + ( + json!({"text":"Uncaught","columnNumber":0}), + "Uncaught (column 1)", + ), + ( + json!({"text":"Uncaught","lineNumber":-1,"columnNumber":null}), + "Uncaught", + ), + ( + json!({"text":"Uncaught","lineNumber":u64::MAX,"columnNumber":"5"}), + "Uncaught", + ), + ] { + assert_eq!(javascript_exception_message(&details), expected); + } + } + + #[test] + fn exception_summary_is_bounded_without_splitting_unicode() { + for field in ["description", "value", "unserializableValue"] { + let details = json!({"text":"Uncaught","exception":{field:"ι”™πŸ¦€".repeat(600)}}); + let message = javascript_exception_message(&details); + assert_eq!(message, format!("{}…", "ι”™πŸ¦€".repeat(512))); + } + let text = "x".repeat(1024); + assert_eq!(javascript_exception_message(&json!({"text":text})), text); + assert_eq!( + javascript_exception_message(&json!({"text":"x".repeat(1025)})), + format!("{text}…") + ); + } + #[test] fn explicit_page_selection_does_not_depend_on_order() { let home = json!({"type":"page","targetId":"home"}); diff --git a/src/main.rs b/src/main.rs index 77042cb..dfa17eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,9 @@ -use std::{path::PathBuf, process::ExitCode, time::Duration}; +use std::{ + io::{self, Write}, + path::PathBuf, + process::ExitCode, + time::Duration, +}; use clap::{Args, Parser, Subcommand}; use lexmount_browser::{ @@ -6,7 +11,6 @@ use lexmount_browser::{ cdp::{Cdp, WaitTextOptions}, models::CreateSession, }; -use serde::Serialize; use serde_json::{Value, json}; #[derive(Parser)] @@ -282,24 +286,42 @@ enum ActionCommand { } fn main() -> ExitCode { - match run(Cli::parse()) { - Ok(value) => { - print_json(&json!({"ok": true, "data": value})); - ExitCode::SUCCESS - } + let result = run(Cli::parse()); + write_result(result, &mut io::stdout().lock(), &mut io::stderr().lock()) +} + +fn write_result( + result: Result, + stdout: &mut impl Write, + stderr: &mut impl Write, +) -> ExitCode { + match result { + Ok(value) => match print_json(stdout, &json!({"ok": true, "data": value})) { + Ok(()) => ExitCode::SUCCESS, + // A consumer such as `head` may intentionally stop reading. This + // exception applies only after the requested operation succeeded. + Err(error) if error.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS, + Err(error) => { + print_error(stderr, &Error::Io(error)); + ExitCode::FAILURE + } + }, Err(error) => { - eprintln!( - "{}", - serde_json::to_string( - &json!({"ok": false, "error": error_kind(&error), "message": error.to_string()}) - ) - .unwrap() - ); + print_error(stderr, &error); ExitCode::FAILURE } } } +fn print_error(stderr: &mut impl Write, error: &Error) { + // Reporting must not panic or replace the original failure exit status if + // stderr is also closed or unavailable. + let _ = print_json( + stderr, + &json!({"ok": false, "error": error_kind(error), "message": error.to_string()}), + ); +} + fn run(cli: Cli) -> Result { match cli.command { Command::Version => { @@ -576,14 +598,124 @@ fn error_kind(error: &Error) -> &'static str { Error::Cdp(_) => "cdp_error", } } -fn print_json(value: &T) { - println!("{}", serde_json::to_string(value).unwrap()); +fn print_json(writer: &mut impl Write, value: &Value) -> io::Result<()> { + writeln!(writer, "{value}")?; + writer.flush() } #[cfg(test)] mod tests { use super::*; + struct FailingWriter { + kind: io::ErrorKind, + on_flush: bool, + } + + impl Write for FailingWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if self.on_flush { + Ok(bytes.len()) + } else { + Err(io::Error::new(self.kind, "fixture output failure")) + } + } + + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::new(self.kind, "fixture output failure")) + } + } + + #[test] + fn successful_result_allows_broken_pipe_on_write_or_flush() { + for on_flush in [false, true] { + let mut stdout = FailingWriter { + kind: io::ErrorKind::BrokenPipe, + on_flush, + }; + let mut stderr = Vec::new(); + assert_eq!( + write_result(Ok(json!(true)), &mut stdout, &mut stderr), + ExitCode::SUCCESS + ); + assert!(stderr.is_empty()); + } + } + + #[test] + fn other_output_errors_remain_failures() { + for on_flush in [false, true] { + let mut stdout = FailingWriter { + kind: io::ErrorKind::PermissionDenied, + on_flush, + }; + let mut stderr = Vec::new(); + assert_eq!( + write_result(Ok(json!(true)), &mut stdout, &mut stderr), + ExitCode::FAILURE + ); + let error: Value = serde_json::from_slice(&stderr).unwrap(); + assert_eq!(error["ok"], false); + assert_eq!(error["error"], "io_error"); + assert!( + error["message"] + .as_str() + .unwrap() + .contains("fixture output failure") + ); + } + } + + #[test] + fn action_failures_are_preserved_when_stderr_fails() { + for kind in [io::ErrorKind::BrokenPipe, io::ErrorKind::PermissionDenied] { + for on_flush in [false, true] { + let mut stdout = Vec::new(); + let mut stderr = FailingWriter { kind, on_flush }; + assert_eq!( + write_result( + Err(Error::Cdp("fixture error".into())), + &mut stdout, + &mut stderr + ), + ExitCode::FAILURE + ); + assert!(stdout.is_empty()); + } + } + } + + #[test] + fn output_envelopes_and_escaping_are_unchanged() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let message = "δΈ­ζ–‡\n\"quoted\"\\path\u{1b}"; + let value = json!({"text":message}); + assert_eq!( + write_result(Ok(value.clone()), &mut stdout, &mut stderr), + ExitCode::SUCCESS + ); + assert_eq!( + serde_json::from_slice::(&stdout).unwrap(), + json!({"ok":true,"data":value}) + ); + assert!(stdout.ends_with(b"\n")); + assert_eq!(stdout.iter().filter(|b| **b == b'\n').count(), 1); + assert!(stderr.is_empty()); + stdout.clear(); + assert_eq!( + write_result(Err(Error::Cdp(message.into())), &mut stdout, &mut stderr), + ExitCode::FAILURE + ); + assert!(stdout.is_empty()); + assert_eq!( + serde_json::from_slice::(&stderr).unwrap(), + json!({"ok":false,"error":"cdp_error","message":format!("CDP command failed: {message}")}) + ); + assert!(stderr.ends_with(b"\n")); + assert_eq!(stderr.iter().filter(|b| **b == b'\n').count(), 1); + } + #[test] fn page_target_flag_is_accepted_before_or_after_action_subcommand() { for args in [ diff --git a/tests/output_streams.rs b/tests/output_streams.rs new file mode 100644 index 0000000..a233ec2 --- /dev/null +++ b/tests/output_streams.rs @@ -0,0 +1,140 @@ +//! Real process/pipe tests with a gated loopback-only HTTP response. +mod support; + +use serde_json::{Value, json}; +use std::{ + io::{BufRead, BufReader, ErrorKind, Read, Write}, + net::TcpListener, + process::Output, + sync::mpsc, + thread, + time::{Duration, Instant}, +}; + +fn closed_reader(fail_action: bool, close_stderr: bool) -> Output { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let api = format!("http://{}", listener.local_addr().unwrap()); + let (ready_tx, ready_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let server = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(10); + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) + if error.kind() == ErrorKind::WouldBlock && Instant::now() < deadline => + { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("fixture accept: {error}"), + } + }; + 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 reader = BufReader::new(stream); + let mut length = 0; + let mut header_bytes = 0; + loop { + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + header_bytes += line.len(); + assert!(header_bytes < 16384); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse::().unwrap(); + } + } + assert!(length < 65536); + reader.read_exact(&mut vec![0; length]).unwrap(); + ready_tx.send(()).unwrap(); + // Do not let the CLI finish until the test has closed its pipe reader. + release_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + let (status, body) = if fail_action { + ( + "401 Unauthorized", + json!({"message":"local fixture refused"}), + ) + } else { + ("200 OK", json!({"session_id":"fixture","status":"active"})) + }; + let body = body.to_string(); + write!(reader.get_mut(), "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + }); + let directory = tempfile::tempdir().unwrap(); + let mut child = support::command( + &api, + directory.path(), + &["session", "get", "--session-id", "fixture"], + ) + .spawn() + .unwrap(); + if ready_rx.recv_timeout(Duration::from_secs(15)).is_err() { + let _ = child.kill(); + let output = child.wait_with_output().unwrap(); + let _ = server.join(); + panic!( + "CLI did not reach fixture: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + drop(child.stdout.take()); + if close_stderr { + drop(child.stderr.take()); + } + release_tx.send(()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + let _ = server.join(); + panic!("CLI did not exit after pipe closed"); + } + thread::sleep(Duration::from_millis(10)); + } + let output = child.wait_with_output().unwrap(); + server.join().unwrap(); + output +} + +#[test] +fn closed_stdout_after_success_does_not_panic() { + let output = closed_reader(false, false); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stderr.is_empty()); +} + +#[test] +fn closing_stdout_does_not_hide_an_action_failure() { + let output = closed_reader(true, false); + assert_eq!(output.status.code(), Some(1)); + let error: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(error["ok"], false); +} + +#[test] +fn closed_stderr_preserves_failure_exit_without_panic() { + let output = closed_reader(true, true); + assert_eq!(output.status.code(), Some(1)); +} + +#[test] +fn version_still_emits_a_single_json_document() { + let directory = tempfile::tempdir().unwrap(); + let output = support::cli("http://127.0.0.1:1", directory.path(), &["version"]); + assert!(output.stdout.ends_with(b"\n")); + assert_eq!(output.stdout.iter().filter(|b| **b == b'\n').count(), 1); + assert_eq!(support::data(output)["version"], env!("CARGO_PKG_VERSION")); +} diff --git a/tests/page_targets.rs b/tests/page_targets.rs index 1fbe0be..be41951 100644 --- a/tests/page_targets.rs +++ b/tests/page_targets.rs @@ -20,6 +20,10 @@ struct Peer { impl Peer { fn start(targets: Value, fail_attach: bool) -> Self { + Self::with_exception(targets, fail_attach, None) + } + + fn with_exception(targets: Value, fail_attach: bool, exception: Option) -> Self { let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); listener.set_nonblocking(true).unwrap(); let url = format!("ws://{}", listener.local_addr().unwrap()); @@ -106,6 +110,21 @@ impl Peer { json!({"data":"YXJ0aWZhY3Q="}) } "Runtime.evaluate" => { + if let Some(details) = &exception { + socket + .send(Message::Text( + json!({ + "id":request["id"], "result":{ + "result":{"type":"object","subtype":"error"}, + "exceptionDetails":details + } + }) + .to_string() + .into(), + )) + .unwrap(); + continue; + } let expression = request["params"]["expression"].as_str().unwrap(); let value = match expression { "document.readyState" => json!("complete"), @@ -165,6 +184,81 @@ fn two_pages() -> Value { ]) } +#[test] +fn evaluation_exception_details_reach_cli_without_changing_failure_contract() { + let directory = tempfile::tempdir().unwrap(); + for args in [ + vec![ + "eval", + "--expression", + "document.querySelector('#missing').click()", + ], + vec!["click", "--selector", "#missing"], + vec!["fill", "--selector", "#missing", "--value", "hello"], + ] { + let peer = Peer::with_exception( + two_pages(), + false, + Some(json!({ + "text":"Uncaught", "lineNumber":2, "columnNumber":4, + "url":"https://example.test/?token=private-source-url", + "exception":{ + "className":"TypeError", "objectId":"private-object-id", + "description":"TypeError: Cannot read properties of null (reading 'click')\n at https://example.test/?token=private-stack-url:3:5" + }, + "stackTrace":{"callFrames":[{"url":"private-stack-frame"}]} + })), + ); + let server = api(&peer.url); + let mut arguments = vec!["action"]; + arguments.extend(args); + arguments.extend(["--session-id", "browser", "--target-id", "wanted"]); + let output = support::cli(&server.base_url(), directory.path(), &arguments); + 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, + json!({"ok":false,"error":"cdp_error", + "message":"CDP command failed: TypeError: Cannot read properties of null (reading 'click') (line 3, column 5)"}) + ); + let seen = peer.finish(); + assert_eq!( + seen.iter() + .filter(|r| r["method"] == "Runtime.evaluate") + .count(), + 1 + ); + } +} + +#[test] +fn sdk_evaluation_preserves_syntax_errors() { + if support::isolated_test( + "sdk_evaluation_preserves_syntax_errors", + Duration::from_secs(20), + ) { + return; + } + let peer = Peer::with_exception( + two_pages(), + false, + Some(json!({ + "text":"Uncaught", "lineNumber":0, "columnNumber":20, + "exception":{"className":"SyntaxError", "description":"SyntaxError: Invalid regular expression: missing /"} + })), + ); + let error = { + let mut cdp = Cdp::connect_to_target(&peer.url, "wanted").unwrap(); + cdp.evaluate("invalid syntax").unwrap_err() + }; + assert_eq!( + error.to_string(), + "CDP command failed: SyntaxError: Invalid regular expression: missing / (line 1, column 21)" + ); + peer.finish(); +} + #[test] fn sdk_selects_the_requested_target_regardless_of_enumeration_order() { if support::isolated_test( diff --git a/tests/page_targets_browser.rs b/tests/page_targets_browser.rs index 24a680f..09ca874 100644 --- a/tests/page_targets_browser.rs +++ b/tests/page_targets_browser.rs @@ -121,6 +121,95 @@ fn pages(cdp: &mut Cdp) -> Vec { .collect() } +#[test] +#[ignore = "requires BROWSER_CLI_TEST_CHROME pointing to a Chrome/Chromium executable"] +fn javascript_errors_are_actionable_with_a_real_browser() { + if support::isolated_test( + "javascript_errors_are_actionable_with_a_real_browser", + Duration::from_secs(120), + ) { + return; + } + 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 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})); + }); + for (args, expected) in [ + ( + vec!["click", "--selector", "#missing"], + "Error: selector not found", + ), + ( + vec!["fill", "--selector", "#missing", "--value", "test"], + "Error: selector not found", + ), + ( + vec![ + "eval", + "--expression", + "document.querySelector('#missing').click()", + ], + "TypeError:", + ), + (vec!["eval", "--expression", "/[/"], "SyntaxError:"), + ( + vec!["eval", "--expression", "throw 'not ready'"], + "not ready", + ), + ( + vec![ + "eval", + "--expression", + "Promise.reject(new Error('not ready'))", + ], + "Error: not ready", + ), + ] { + let mut arguments = vec!["action"]; + arguments.extend(args); + arguments.extend(["--session-id", "browser"]); + let output = support::cli(&api.base_url(), directory.path(), &arguments); + 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["ok"], false); + assert_eq!(error["error"], "cdp_error"); + let message = error["message"].as_str().unwrap(); + assert!(message.contains(expected), "{message}"); + assert!(message.contains("(line "), "{message}"); + assert!(!message.contains('\n'), "stack should not be appended"); + assert_eq!( + support::data(support::cli( + &api.base_url(), + directory.path(), + &[ + "action", + "eval", + "--session-id", + "browser", + "--expression", + "1+1" + ] + )), + 2 + ); + } + let mut observer = Cdp::connect(&browser.websocket).unwrap(); + let version = observer + .command_root("Browser.getVersion", json!({})) + .unwrap(); + println!( + "{}", + json!({"browser":version["product"], "error_cases":6, "success_after_each_error":true}) + ); +} + #[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() { diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 2641a96..dd13bc8 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -7,7 +7,7 @@ use std::{ }; // Run the real binary against a loopback fixture, never the user's credentials. -pub fn cli(api: &str, directory: &Path, arguments: &[&str]) -> Output { +pub fn command(api: &str, directory: &Path, arguments: &[&str]) -> Command { assert!(api.starts_with("http://127.0.0.1:")); let mut command = Command::new(env!("CARGO_BIN_EXE_browser-cli")); command @@ -25,7 +25,16 @@ pub fn cli(api: &str, directory: &Path, arguments: &[&str]) -> Output { .stdout(Stdio::piped()) .stderr(Stdio::piped()); clear_proxy_env(&mut command); - run(command, Duration::from_secs(20)) + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); // CREATE_NO_WINDOW + } + command +} + +pub fn cli(api: &str, directory: &Path, arguments: &[&str]) -> Output { + run(command(api, directory, arguments), Duration::from_secs(20)) } fn clear_proxy_env(command: &mut Command) {