mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
501 lines
18 KiB
Rust
501 lines
18 KiB
Rust
//! Top-level CLI error type and rendering entry point.
|
|
//!
|
|
//! The single source of truth for `ErrorCode → { summary, hint,
|
|
//! exit_code }` lives in [`super::render_error`]; this module owns
|
|
//! [`CliError`] (the wrapper carrying daemon `RpcError`, transport
|
|
//! failures, and already-rendered errors) plus the [`render`]
|
|
//! function every business / admin command funnels through.
|
|
//!
|
|
//! Exit codes are documented in [`super::render_error`] and
|
|
//! design §3.1.
|
|
|
|
use std::io::Write;
|
|
use std::process::ExitCode;
|
|
|
|
use anyhow::Error;
|
|
use bsk_protocol::{ErrorCode, RpcError};
|
|
use serde::Serialize;
|
|
use thiserror::Error;
|
|
|
|
use super::render_error;
|
|
|
|
/// Strongly-typed CLI error. Wraps either a structured daemon error or
|
|
/// a transport / setup failure (`anyhow::Error`).
|
|
#[derive(Debug, Error)]
|
|
pub enum CliError {
|
|
/// Structured error reported by the daemon (or extension via the
|
|
/// daemon's RPC layer).
|
|
#[error("{message}")]
|
|
Rpc {
|
|
code: ErrorCode,
|
|
message: String,
|
|
/// Optional structured payload the daemon attaches for richer
|
|
/// rendering (M10.1: `multiple_browsers_online` carries the
|
|
/// browser list; `invalid_params` for ambiguous label match
|
|
/// carries the matching instance ids).
|
|
data: Option<serde_json::Value>,
|
|
#[source]
|
|
source: Option<Error>,
|
|
},
|
|
|
|
/// Command already rendered its structured payload; main should only
|
|
/// return the matching non-zero exit code.
|
|
#[error("{message}")]
|
|
Rendered { code: ErrorCode, message: String },
|
|
|
|
/// Command already rendered its result and only needs to communicate
|
|
/// a command-specific non-zero status. Unlike `Rendered`, this is not
|
|
/// pretending that a daemon protocol error occurred.
|
|
#[error("command reported an unsuccessful status")]
|
|
RenderedExit { exit_code: u8 },
|
|
|
|
/// Local transport / setup failure (e.g. couldn't reach the
|
|
/// daemon, JSON encode failed). Maps to exit code 2.
|
|
#[error(transparent)]
|
|
Local(#[from] Error),
|
|
}
|
|
|
|
impl CliError {
|
|
pub fn from_rpc(err: RpcError) -> Self {
|
|
CliError::Rpc {
|
|
code: err.code,
|
|
message: err.message,
|
|
data: err.data,
|
|
source: None,
|
|
}
|
|
}
|
|
|
|
pub fn code(&self) -> Option<ErrorCode> {
|
|
match self {
|
|
CliError::Rpc { code, .. } => Some(*code),
|
|
CliError::Rendered { code, .. } => Some(*code),
|
|
CliError::RenderedExit { .. } => None,
|
|
CliError::Local(_) => None,
|
|
}
|
|
}
|
|
|
|
pub fn data(&self) -> Option<&serde_json::Value> {
|
|
match self {
|
|
CliError::Rpc { data, .. } => data.as_ref(),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Map this error to the CLI exit code (§3.1).
|
|
pub fn exit_code(&self) -> u8 {
|
|
if let CliError::RenderedExit { exit_code } = self {
|
|
return *exit_code;
|
|
}
|
|
match self.code() {
|
|
Some(code) => exit_code_for(code),
|
|
None => 2, // local / protocol-level transport failure
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Re-exported convenience wrapper around
|
|
/// [`render_error::exit_code_for`] so existing callers keep working.
|
|
pub fn exit_code_for(code: ErrorCode) -> u8 {
|
|
render_error::exit_code_for(code)
|
|
}
|
|
|
|
/// Output rendering format selector for top-level CLI errors.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Format {
|
|
Human,
|
|
Json,
|
|
}
|
|
|
|
/// `--json` error shape. Top-level fields (review I2 fix to round-1):
|
|
/// `{ code, message, hint, exit_code, data }`. The previous shape
|
|
/// nested everything under an `"error"` key — design §10 / §3.1
|
|
/// describe `error.code` as the daemon-level field name on the
|
|
/// **wire**, not as a CLI rendering envelope, so the extra
|
|
/// indirection is a leftover from M3.5 that was easy to script
|
|
/// against.
|
|
#[derive(Debug, Serialize)]
|
|
struct JsonError {
|
|
code: Option<ErrorCode>,
|
|
message: String,
|
|
hint: Option<&'static str>,
|
|
exit_code: u8,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
data: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// Build the `--json` payload for a [`CliError`]. Pulled out of
|
|
/// [`render`] so the JSON shape can be unit-tested without capturing
|
|
/// stdout (review I2 fix to round-1).
|
|
fn json_error_string(err: &CliError, exit: u8, hint: Option<&'static str>) -> String {
|
|
let body = JsonError {
|
|
code: err.code(),
|
|
message: err.to_string(),
|
|
hint,
|
|
exit_code: exit,
|
|
data: err.data().cloned(),
|
|
};
|
|
serde_json::to_string_pretty(&body)
|
|
.unwrap_or_else(|_| "{\"message\":\"failed to encode error\"}".into())
|
|
}
|
|
|
|
/// Renderer extension hook for commands that want to inject a
|
|
/// structured "extra section" between the centralised summary and
|
|
/// the centralised hint (review I3 fix to round-1).
|
|
///
|
|
/// Implementations write directly to a generic `&mut dyn Write` so
|
|
/// tests can capture into a `Vec<u8>` and assert on the rendered
|
|
/// stderr output. Production callers receive a write handle wrapping
|
|
/// `std::io::stderr()`.
|
|
///
|
|
/// Examples: `bsk session start` writes the connected-browsers table
|
|
/// here for `multiple_browsers_online`; the matching ambiguous-label
|
|
/// error writes a bullet list of candidate `instance_id`s.
|
|
pub trait RenderExtras {
|
|
fn write_extras(&self, out: &mut dyn std::io::Write) -> std::io::Result<()>;
|
|
}
|
|
|
|
impl<F> RenderExtras for F
|
|
where
|
|
F: Fn(&mut dyn std::io::Write) -> std::io::Result<()>,
|
|
{
|
|
fn write_extras(&self, out: &mut dyn std::io::Write) -> std::io::Result<()> {
|
|
self(out)
|
|
}
|
|
}
|
|
|
|
/// Resolve centralised render metadata for a [`CliError`], when it
|
|
/// carries an [`ErrorCode`].
|
|
fn render_info_for(err: &CliError) -> Option<render_error::RenderInfo> {
|
|
err.code()
|
|
.map(|code| render_error::info_for_error(code, err.data()))
|
|
}
|
|
|
|
/// Hint line for human / JSON rendering, including the local-failure
|
|
/// fallback when there is no daemon [`ErrorCode`].
|
|
fn hint_for(
|
|
err: &CliError,
|
|
render_info: Option<&render_error::RenderInfo>,
|
|
) -> Option<&'static str> {
|
|
render_info
|
|
.and_then(|info| info.hint)
|
|
.or(if matches!(err, CliError::Local(_)) {
|
|
Some("is the daemon running? try `bsk daemon start` or `bsk status`")
|
|
} else {
|
|
None
|
|
})
|
|
}
|
|
|
|
/// Render an error to stderr (human-readable) or stdout (`--json`),
|
|
/// returning the matching `ExitCode`.
|
|
///
|
|
/// Both formats route through [`render_error::info_for`] so the
|
|
/// summary / hint / exit-code stay in sync. Commands that have
|
|
/// already printed a bespoke human-readable rendering must wrap
|
|
/// their result in [`CliError::Rendered`] so this function only
|
|
/// surfaces the matching exit code.
|
|
pub fn render(err: &CliError, format: Format) -> ExitCode {
|
|
render_with_extras(err, format, None)
|
|
}
|
|
|
|
/// Variant of [`render`] that lets a command inject a per-error
|
|
/// "extra section" between the summary and the hint without giving
|
|
/// up the centralised summary/hint/exit-code table (review I3).
|
|
///
|
|
/// In `--json` mode the extras are skipped — the structured payload
|
|
/// already lives in `error.data` and per-command renderers shouldn't
|
|
/// duplicate it. In human mode the order is:
|
|
///
|
|
/// ```text
|
|
/// error: <centralised summary>
|
|
/// <extras (caller-controlled)>
|
|
/// hint: <centralised hint>
|
|
/// details: <raw daemon message, when it adds context>
|
|
/// ```
|
|
pub fn render_with_extras(
|
|
err: &CliError,
|
|
format: Format,
|
|
extras: Option<&dyn RenderExtras>,
|
|
) -> ExitCode {
|
|
let exit = err.exit_code();
|
|
if matches!(
|
|
err,
|
|
CliError::Rendered { .. } | CliError::RenderedExit { .. }
|
|
) {
|
|
return ExitCode::from(exit);
|
|
}
|
|
let render_info = render_info_for(err);
|
|
let hint = hint_for(err, render_info.as_ref());
|
|
|
|
match format {
|
|
Format::Json => {
|
|
let json = json_error_string(err, exit, hint);
|
|
println!("{json}");
|
|
}
|
|
Format::Human => {
|
|
let summary = match render_info {
|
|
Some(info) => info.summary.to_string(),
|
|
None => err.to_string(),
|
|
};
|
|
let raw = err.to_string();
|
|
let mut out = std::io::stderr();
|
|
// Prefer the friendly summary from the `render_error`
|
|
// table so users see a clear English error message instead
|
|
// of the daemon's terse machine-oriented message. Fall back to
|
|
// whatever the wrapped error renders for transport /
|
|
// setup failures that have no `ErrorCode`.
|
|
let _ = writeln!(out, "error: {summary}");
|
|
if let Some(extras) = extras
|
|
&& let Err(e) = extras.write_extras(&mut out)
|
|
{
|
|
let _ = writeln!(out, "(failed to render extras: {e})");
|
|
}
|
|
if let Some(h) = hint {
|
|
let _ = writeln!(out, "hint: {h}");
|
|
}
|
|
// When the wrapped daemon message adds context the
|
|
// summary hides ("session reservation vanished before
|
|
// commit", a CDP description, etc.), surface it on a
|
|
// `details:` line so power users still see it without
|
|
// burying the friendly summary.
|
|
if err.code().is_some() && !raw.is_empty() && raw != summary {
|
|
let _ = writeln!(out, "details: {raw}");
|
|
}
|
|
}
|
|
}
|
|
ExitCode::from(exit)
|
|
}
|
|
|
|
/// Test-only helper that writes the same human-mode output [`render`]
|
|
/// would emit to stderr into a string. Used by `bsk session start`'s
|
|
/// I3 unit tests to lock in the "summary → extras → hint → details"
|
|
/// order without redirecting the real stderr.
|
|
#[cfg(test)]
|
|
pub(crate) fn render_human_to_string(err: &CliError, extras: Option<&dyn RenderExtras>) -> String {
|
|
let mut buf: Vec<u8> = Vec::new();
|
|
if matches!(
|
|
err,
|
|
CliError::Rendered { .. } | CliError::RenderedExit { .. }
|
|
) {
|
|
return String::new();
|
|
}
|
|
let render_info = render_info_for(err);
|
|
let summary = match render_info {
|
|
Some(info) => info.summary.to_string(),
|
|
None => err.to_string(),
|
|
};
|
|
let hint = hint_for(err, render_info.as_ref());
|
|
let _ = writeln!(buf, "error: {summary}");
|
|
if let Some(extras) = extras
|
|
&& let Err(e) = extras.write_extras(&mut buf)
|
|
{
|
|
let _ = writeln!(buf, "(failed to render extras: {e})");
|
|
}
|
|
if let Some(h) = hint {
|
|
let _ = writeln!(buf, "hint: {h}");
|
|
}
|
|
let raw = err.to_string();
|
|
if err.code().is_some() && !raw.is_empty() && raw != summary {
|
|
let _ = writeln!(buf, "details: {raw}");
|
|
}
|
|
String::from_utf8(buf).unwrap_or_default()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn exit_code_matches_design_table() {
|
|
// Mirror of `render_error::tests::exit_codes_match_design_table`
|
|
// — keeps both call sites in lockstep with design §3.1.
|
|
assert_eq!(exit_code_for(ErrorCode::InvalidParams), 1);
|
|
assert_eq!(exit_code_for(ErrorCode::NotFound), 1);
|
|
assert_eq!(exit_code_for(ErrorCode::PermissionDenied), 1);
|
|
assert_eq!(exit_code_for(ErrorCode::NoBrowserConnected), 1);
|
|
assert_eq!(exit_code_for(ErrorCode::MultipleBrowsersOnline), 1);
|
|
assert_eq!(exit_code_for(ErrorCode::Unsupported), 1);
|
|
|
|
assert_eq!(exit_code_for(ErrorCode::ProtocolError), 2);
|
|
assert_eq!(exit_code_for(ErrorCode::Cancelled), 2);
|
|
|
|
assert_eq!(exit_code_for(ErrorCode::CdpFailed), 3);
|
|
assert_eq!(exit_code_for(ErrorCode::Timeout), 4);
|
|
// Review I4: `unknown_method` is a version-mismatch symptom
|
|
// and lives in the version-incompatibility bucket, not the
|
|
// user-error bucket.
|
|
assert_eq!(exit_code_for(ErrorCode::UnknownMethod), 5);
|
|
assert_eq!(exit_code_for(ErrorCode::VersionTooOld), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn cli_error_from_rpc_preserves_code() {
|
|
let rpc = RpcError {
|
|
code: ErrorCode::NoBrowserConnected,
|
|
message: "no browser".into(),
|
|
data: None,
|
|
};
|
|
let cli = CliError::from_rpc(rpc);
|
|
assert_eq!(cli.code(), Some(ErrorCode::NoBrowserConnected));
|
|
assert_eq!(cli.exit_code(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn cli_error_from_anyhow_is_protocol_level() {
|
|
let any: CliError = anyhow::anyhow!("daemon unreachable").into();
|
|
assert_eq!(any.code(), None);
|
|
assert_eq!(any.exit_code(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn rendered_error_uses_structured_exit_code() {
|
|
let err = CliError::Rendered {
|
|
code: ErrorCode::CdpFailed,
|
|
message: "already rendered".into(),
|
|
};
|
|
assert_eq!(err.code(), Some(ErrorCode::CdpFailed));
|
|
assert_eq!(err.exit_code(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn rendered_exit_uses_command_specific_status_without_protocol_code() {
|
|
let err = CliError::RenderedExit { exit_code: 1 };
|
|
assert_eq!(err.code(), None);
|
|
assert_eq!(err.exit_code(), 1);
|
|
}
|
|
|
|
/// Review I2: the `--json` payload must be flat at the top level.
|
|
/// The previous shape nested everything under `"error"` which was
|
|
/// awkward for shell scripts (`jq -r '.error.code'`) and contradicted
|
|
/// the design's wire-level naming.
|
|
#[test]
|
|
fn json_error_shape_is_flat() {
|
|
let cli = CliError::from_rpc(RpcError {
|
|
code: ErrorCode::MultipleBrowsersOnline,
|
|
message: "more than one browser".into(),
|
|
data: Some(serde_json::json!({"browsers": [{"instance_id": "alpha"}]})),
|
|
});
|
|
let exit = cli.exit_code();
|
|
let hint = hint_for(&cli, render_info_for(&cli).as_ref());
|
|
let json = json_error_string(&cli, exit, hint);
|
|
let parsed: serde_json::Value =
|
|
serde_json::from_str(&json).expect("--json output must parse");
|
|
assert_eq!(
|
|
parsed.get("code"),
|
|
Some(&serde_json::json!("multiple_browsers_online"))
|
|
);
|
|
assert_eq!(parsed.get("exit_code"), Some(&serde_json::json!(1)));
|
|
assert_eq!(
|
|
parsed.get("message"),
|
|
Some(&serde_json::json!("more than one browser"))
|
|
);
|
|
assert!(
|
|
parsed.get("hint").and_then(|v| v.as_str()).is_some(),
|
|
"every code in the table provides a hint today"
|
|
);
|
|
assert_eq!(
|
|
parsed.get("data"),
|
|
Some(&serde_json::json!({"browsers": [{"instance_id": "alpha"}]}))
|
|
);
|
|
assert!(
|
|
parsed.get("error").is_none(),
|
|
"the deprecated `error` envelope must not appear in --json output"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn json_error_shape_omits_data_and_code_for_local_failures() {
|
|
let local: CliError = anyhow::anyhow!("daemon unreachable").into();
|
|
let exit = local.exit_code();
|
|
let hint = hint_for(&local, render_info_for(&local).as_ref()).or(Some(
|
|
"is the daemon running? try `bsk daemon start` or `bsk status`",
|
|
));
|
|
let json = json_error_string(&local, exit, hint);
|
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.get("code"), Some(&serde_json::Value::Null));
|
|
assert!(
|
|
parsed.get("data").is_none(),
|
|
"no data field for local failures"
|
|
);
|
|
assert_eq!(parsed.get("exit_code"), Some(&serde_json::json!(2)));
|
|
assert_eq!(
|
|
parsed.get("message"),
|
|
Some(&serde_json::json!("daemon unreachable"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permission_denied_element_not_visible_renders_geometry_hint() {
|
|
let cli = CliError::from_rpc(RpcError {
|
|
code: ErrorCode::PermissionDenied,
|
|
message: "element not visible (no content quads)".into(),
|
|
data: Some(serde_json::json!({ "reason": "element_not_visible" })),
|
|
});
|
|
let stderr = render_human_to_string(&cli, None);
|
|
assert!(stderr.contains("target element has no visible geometry"));
|
|
assert!(stderr.contains("rerun snapshot"));
|
|
assert!(!stderr.contains("Agent Window sandbox"));
|
|
assert!(!stderr.contains("tab borrow"));
|
|
assert!(stderr.contains("details: element not visible"));
|
|
}
|
|
|
|
#[test]
|
|
fn permission_denied_agent_window_scope_keeps_sandbox_hint() {
|
|
let cli = CliError::from_rpc(RpcError {
|
|
code: ErrorCode::PermissionDenied,
|
|
message: "click can only act on tabs inside the Agent Window".into(),
|
|
data: Some(serde_json::json!({ "reason": "agent_window_scope" })),
|
|
});
|
|
let stderr = render_human_to_string(&cli, None);
|
|
assert!(stderr.contains("operation denied by the Agent Window sandbox"));
|
|
assert!(stderr.contains("tab borrow"));
|
|
}
|
|
|
|
#[test]
|
|
fn not_found_ref_uses_snapshot_hint_in_json() {
|
|
let cli = CliError::from_rpc(RpcError {
|
|
code: ErrorCode::NotFound,
|
|
message: "ref @e99 unknown for tab 7".into(),
|
|
data: Some(serde_json::json!({ "reason": "ref_not_found" })),
|
|
});
|
|
let exit = cli.exit_code();
|
|
let hint = hint_for(&cli, render_info_for(&cli).as_ref());
|
|
let json = json_error_string(&cli, exit, hint);
|
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.get("code"), Some(&serde_json::json!("not_found")));
|
|
assert!(
|
|
parsed
|
|
.get("hint")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap()
|
|
.contains("bsk snapshot")
|
|
);
|
|
assert!(
|
|
!parsed
|
|
.get("hint")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap()
|
|
.contains("bsk browsers")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn not_found_selector_uses_selector_hint_in_json() {
|
|
let cli = CliError::from_rpc(RpcError {
|
|
code: ErrorCode::NotFound,
|
|
message: "selector .missing did not match".into(),
|
|
data: Some(serde_json::json!({ "reason": "selector_not_found" })),
|
|
});
|
|
let exit = cli.exit_code();
|
|
let hint = hint_for(&cli, render_info_for(&cli).as_ref());
|
|
let json = json_error_string(&cli, exit, hint);
|
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
assert!(
|
|
parsed
|
|
.get("hint")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap()
|
|
.contains("CSS selector")
|
|
);
|
|
}
|
|
}
|