Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
142 changes: 134 additions & 8 deletions src/cdp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<String> {
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<Option<String>> {
let pages = targets.get("targetInfos").and_then(Value::as_array);
if let Some(id) = requested {
Expand Down Expand Up @@ -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"});
Expand Down
164 changes: 148 additions & 16 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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::{
Client, Error, Result, auth,
cdp::{Cdp, WaitTextOptions},
models::CreateSession,
};
use serde::Serialize;
use serde_json::{Value, json};

#[derive(Parser)]
Expand Down Expand Up @@ -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<Value>,
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<Value> {
match cli.command {
Command::Version => {
Expand Down Expand Up @@ -576,14 +598,124 @@ fn error_kind(error: &Error) -> &'static str {
Error::Cdp(_) => "cdp_error",
}
}
fn print_json<T: Serialize>(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<usize> {
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::<Value>(&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::<Value>(&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 [
Expand Down
Loading
Loading