mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat: add console observation command
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
//! `bsk console` — read buffered page console/log/exception messages.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use bsk_protocol::Method;
|
||||
use bsk_protocol::tools::{ConsoleEntry, ConsoleParams, ConsoleResult};
|
||||
use clap::Args;
|
||||
|
||||
use crate::cli::TOOL_IPC_TIMEOUT;
|
||||
use crate::cli::ensure_daemon::ensure_daemon;
|
||||
use crate::cli::error::{CliError, Format};
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct ConsoleArgs {
|
||||
/// Session id (must be active).
|
||||
#[arg(long)]
|
||||
pub session: String,
|
||||
|
||||
/// Target tab. Defaults to the Agent Window's active tab.
|
||||
#[arg(long = "tab-id")]
|
||||
pub tab_id: Option<i64>,
|
||||
|
||||
/// Return entries with sequence greater than this cursor.
|
||||
#[arg(long)]
|
||||
pub since: Option<u64>,
|
||||
|
||||
/// Maximum number of entries to return. Defaults to 50; extension caps at 200.
|
||||
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
|
||||
pub limit: Option<u32>,
|
||||
|
||||
/// Maximum characters per entry text. Defaults to 1000; extension caps at 4096.
|
||||
#[arg(long = "max-text-chars", value_parser = clap::value_parser!(u32).range(1..))]
|
||||
pub max_text_chars: Option<u32>,
|
||||
|
||||
/// Include structured stack frames in JSON/human output.
|
||||
#[arg(long = "include-stack", default_value_t = false)]
|
||||
pub include_stack: bool,
|
||||
}
|
||||
|
||||
pub fn dispatch(args: ConsoleArgs, format: Format) -> Result<(), CliError> {
|
||||
let info = ensure_daemon().context("ensure daemon is running")?;
|
||||
run(info.sock_path, args, format)
|
||||
}
|
||||
|
||||
fn run(sock: PathBuf, args: ConsoleArgs, format: Format) -> Result<(), CliError> {
|
||||
let params = ConsoleParams {
|
||||
session_id: args.session,
|
||||
tab_id: args.tab_id,
|
||||
since: args.since,
|
||||
limit: args.limit,
|
||||
max_text_chars: args.max_text_chars,
|
||||
include_stack: args.include_stack.then_some(true),
|
||||
};
|
||||
let reply: ConsoleResult = call(sock, params)?;
|
||||
render(&reply, format)
|
||||
}
|
||||
|
||||
fn call(sock: PathBuf, params: ConsoleParams) -> Result<ConsoleResult, CliError> {
|
||||
crate::cli::business_rpc::call::<ConsoleParams, ConsoleResult>(
|
||||
sock,
|
||||
"console",
|
||||
Method::ToolConsole,
|
||||
Some(params),
|
||||
TOOL_IPC_TIMEOUT,
|
||||
)
|
||||
}
|
||||
|
||||
fn render(reply: &ConsoleResult, format: Format) -> Result<(), CliError> {
|
||||
match format {
|
||||
Format::Json => {
|
||||
let json = serde_json::to_string_pretty(reply)
|
||||
.map_err(|e| CliError::Local(anyhow::anyhow!(e)))?;
|
||||
println!("{json}");
|
||||
}
|
||||
Format::Human => {
|
||||
if reply.entries.is_empty() {
|
||||
println!("(no console messages captured)");
|
||||
} else {
|
||||
for entry in &reply.entries {
|
||||
println!("{}", render_entry(entry));
|
||||
for frame in &entry.stack_trace {
|
||||
let loc = render_location(frame.url.as_deref(), frame.line, frame.column);
|
||||
let name = frame.function_name.as_deref().unwrap_or("<anonymous>");
|
||||
println!(" at {name} {loc}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if reply.truncated {
|
||||
eprintln!(
|
||||
"warning: console output truncated (next_since={}). Use --since / --limit / --max-text-chars to request a different slice.",
|
||||
reply.next_since
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_entry(entry: &ConsoleEntry) -> String {
|
||||
let loc = render_location(entry.url.as_deref(), entry.line, entry.column);
|
||||
if loc.is_empty() {
|
||||
format!(
|
||||
"#{} {} {} {}",
|
||||
entry.sequence,
|
||||
entry.level,
|
||||
entry.kind.as_str(),
|
||||
entry.text
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"#{} {} {} {} {}",
|
||||
entry.sequence,
|
||||
entry.level,
|
||||
entry.kind.as_str(),
|
||||
loc,
|
||||
entry.text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_location(url: Option<&str>, line: Option<i64>, column: Option<i64>) -> String {
|
||||
let Some(url) = url else {
|
||||
return String::new();
|
||||
};
|
||||
match (line, column) {
|
||||
(Some(line), Some(column)) => format!("{url}:{line}:{column}"),
|
||||
(Some(line), None) => format!("{url}:{line}"),
|
||||
_ => url.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use std::time::Duration;
|
||||
pub mod browser_wait;
|
||||
pub mod browsers;
|
||||
pub mod business_rpc;
|
||||
pub mod console;
|
||||
pub mod daemon;
|
||||
pub mod dialogs;
|
||||
pub mod doctor;
|
||||
@@ -27,6 +28,7 @@ pub mod waits;
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
|
||||
use crate::cli::console::ConsoleArgs;
|
||||
use crate::cli::daemon::DaemonCmd;
|
||||
use crate::cli::evaluate::EvaluateArgs;
|
||||
use crate::cli::get_html::GetHtmlArgs;
|
||||
@@ -110,6 +112,9 @@ pub enum Command {
|
||||
/// Produce an indented aria-snapshot with @eN refs.
|
||||
Snapshot(SnapshotArgs),
|
||||
|
||||
/// Read buffered console/log/exception messages.
|
||||
Console(ConsoleArgs),
|
||||
|
||||
/// Dump raw HTML for a tab or a snapshot ref.
|
||||
#[command(name = "get-html")]
|
||||
GetHtml(GetHtmlArgs),
|
||||
|
||||
@@ -228,6 +228,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
|
||||
| Method::ToolTabBorrow
|
||||
| Method::ToolTabReturn
|
||||
| Method::ToolScreenshot
|
||||
| Method::ToolConsole
|
||||
| Method::ToolSnapshot
|
||||
| Method::ToolGetHtml
|
||||
| Method::ToolNavigate
|
||||
|
||||
@@ -77,6 +77,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
|
||||
Command::Tab(cmd) => cli::tab::dispatch(cmd, format),
|
||||
Command::Screenshot(args) => cli::screenshot::dispatch(args, format),
|
||||
Command::Snapshot(args) => cli::snapshot::dispatch(args, format),
|
||||
Command::Console(args) => cli::console::dispatch(args, format),
|
||||
Command::GetHtml(args) => cli::get_html::dispatch(args, format),
|
||||
Command::Navigate(args) => cli::navigate::dispatch_navigate_command(args, format),
|
||||
Command::NavigateBack(args) => cli::navigate::dispatch_navigate_back(args, format),
|
||||
|
||||
@@ -67,6 +67,43 @@ fn parses_top_level_status_and_doctor() {
|
||||
assert!(matches!(cli.command, Command::Doctor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_console_command_with_context_safety_flags() {
|
||||
let cli = parse(&[
|
||||
"bsk",
|
||||
"console",
|
||||
"--session",
|
||||
"s1",
|
||||
"--tab-id",
|
||||
"9",
|
||||
"--since",
|
||||
"12",
|
||||
"--limit",
|
||||
"75",
|
||||
"--max-text-chars",
|
||||
"2048",
|
||||
"--include-stack",
|
||||
]);
|
||||
let Command::Console(args) = cli.command else {
|
||||
panic!("expected console command");
|
||||
};
|
||||
assert_eq!(args.session, "s1");
|
||||
assert_eq!(args.tab_id, Some(9));
|
||||
assert_eq!(args.since, Some(12));
|
||||
assert_eq!(args.limit, Some(75));
|
||||
assert_eq!(args.max_text_chars, Some(2048));
|
||||
assert!(args.include_stack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_console_bounds() {
|
||||
assert!(Cli::try_parse_from(["bsk", "console", "--session", "s1", "--limit", "0"]).is_err());
|
||||
assert!(
|
||||
Cli::try_parse_from(["bsk", "console", "--session", "s1", "--max-text-chars", "0"])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_install_skill_subcommand() {
|
||||
let cli = parse(&["bsk", "install-skill", "--list"]);
|
||||
|
||||
@@ -428,8 +428,8 @@ async fn read_only_tool_passes_through_without_consuming_interrupt_marker() {
|
||||
let (ws_sink, ws_stream) = ws.split();
|
||||
let ws_sink = Arc::new(tokio::sync::Mutex::new(ws_sink));
|
||||
|
||||
// Fake extension: replies to session_start AND to snapshot
|
||||
// (snapshot must succeed transparently). Records click count
|
||||
// Fake extension: replies to session_start, console, and snapshot
|
||||
// (read-only tools must succeed transparently). Records click count
|
||||
// — must remain 0 because the daemon should reject the click
|
||||
// without forwarding.
|
||||
let ws_sink_for_responder = Arc::clone(&ws_sink);
|
||||
@@ -454,6 +454,12 @@ async fn read_only_tool_passes_through_without_consuming_interrupt_marker() {
|
||||
})
|
||||
.unwrap(),
|
||||
),
|
||||
Method::ToolConsole => ResponseBody::Ok(json!({
|
||||
"tab_id": 7,
|
||||
"entries": [],
|
||||
"next_since": 0,
|
||||
"truncated": false
|
||||
})),
|
||||
Method::ToolSnapshot => ResponseBody::Ok(json!({"ok": true})),
|
||||
Method::ToolClick => {
|
||||
observed_click_count_clone
|
||||
@@ -511,8 +517,24 @@ async fn read_only_tool_passes_through_without_consuming_interrupt_marker() {
|
||||
let state = handle.state();
|
||||
wait_for_session_interrupt_pending(&state, &start.session_id).await;
|
||||
|
||||
// Read-only snapshot must succeed AND must not consume the
|
||||
// marker.
|
||||
// Read-only console must succeed AND must not consume the marker.
|
||||
let console_outcome = ipc
|
||||
.call::<_, serde_json::Value>(
|
||||
"console-after-interrupt",
|
||||
Method::ToolConsole,
|
||||
Some(json!({"session_id": start.session_id.clone()})),
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
console_outcome.is_ok(),
|
||||
"read-only tool.console must pass through transparently (got {:?})",
|
||||
console_outcome
|
||||
);
|
||||
|
||||
// Existing read-only snapshot must still succeed after console,
|
||||
// proving console did not consume the marker.
|
||||
let snapshot_outcome = ipc
|
||||
.call::<_, serde_json::Value>(
|
||||
"snap-after-interrupt",
|
||||
|
||||
@@ -10,9 +10,9 @@ use bsk::daemon::{self, DaemonConfig};
|
||||
use bsk::ipc_client::IpcClient;
|
||||
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
|
||||
use bsk_protocol::tools::{
|
||||
GetHtmlParams, GetHtmlResult, ScreenshotParams, ScreenshotResult, SessionStartParams,
|
||||
SessionStartResult, SnapshotParams, SnapshotResult, TabInfo, TabListParams, TabListResult,
|
||||
TabScope,
|
||||
ConsoleEntry, ConsoleEntryKind, ConsoleParams, ConsoleResult, GetHtmlParams, GetHtmlResult,
|
||||
ScreenshotParams, ScreenshotResult, SessionStartParams, SessionStartResult, SnapshotParams,
|
||||
SnapshotResult, TabInfo, TabListParams, TabListResult, TabScope,
|
||||
};
|
||||
use bsk_protocol::{
|
||||
BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError,
|
||||
@@ -311,6 +311,63 @@ async fn screenshot_forwards_ref_to_extension() {
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn console_returns_buffered_entries() {
|
||||
let (handle, sock) = spawn_daemon().await;
|
||||
let mut ws = connect_ext(handle.ws_addr()).await;
|
||||
let _ = do_handshake(&mut ws).await;
|
||||
|
||||
run_extension(ws, |req| {
|
||||
assert_eq!(req.method, Method::ToolConsole);
|
||||
let params: ConsoleParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
|
||||
assert_eq!(params.tab_id, Some(7));
|
||||
assert_eq!(params.since, Some(3));
|
||||
assert_eq!(params.limit, Some(50));
|
||||
assert_eq!(params.max_text_chars, Some(1000));
|
||||
assert_eq!(params.include_stack, Some(false));
|
||||
ResponseBody::Ok(
|
||||
serde_json::to_value(ConsoleResult {
|
||||
tab_id: 7,
|
||||
entries: vec![ConsoleEntry {
|
||||
sequence: 4,
|
||||
kind: ConsoleEntryKind::Console,
|
||||
level: "warn".into(),
|
||||
text: "deprecated API".into(),
|
||||
url: Some("https://example.test/app.js".into()),
|
||||
line: Some(10),
|
||||
column: Some(2),
|
||||
timestamp: None,
|
||||
stack_trace: vec![],
|
||||
truncated: false,
|
||||
}],
|
||||
next_since: 4,
|
||||
truncated: false,
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
});
|
||||
|
||||
let session_id = ipc_session_start(&sock).await;
|
||||
let result: ConsoleResult = ipc_tool_call(
|
||||
&sock,
|
||||
Method::ToolConsole,
|
||||
ConsoleParams {
|
||||
session_id,
|
||||
tab_id: Some(7),
|
||||
since: Some(3),
|
||||
limit: Some(50),
|
||||
max_text_chars: Some(1000),
|
||||
include_stack: Some(false),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("console ok");
|
||||
assert_eq!(result.tab_id, 7);
|
||||
assert_eq!(result.next_since, 4);
|
||||
assert_eq!(result.entries[0].text, "deprecated API");
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn snapshot_returns_text_and_ref_count() {
|
||||
let (handle, sock) = spawn_daemon().await;
|
||||
|
||||
Reference in New Issue
Block a user