Initial public release

This commit is contained in:
BB-fat
2026-06-22 17:36:26 +08:00
commit f1036a860c
289 changed files with 54589 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
//! Verify `ensure_daemon` spawns the daemon when none is running.
//!
//! We can't call `ensure_daemon` directly from a test process because
//! `current_exe()` would point to the test binary, not `bsk`. Instead we
//! drive the same effect end-to-end via `bsk status` (which itself calls
//! into `ensure_daemon` in M3.3) — but for M3.2 we test the helper by
//! pointing `current_exe` indirection at the actual `bsk` binary through
//! a small shim test.
#![cfg(unix)]
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, Instant};
use tempfile::TempDir;
fn bsk_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_bsk"))
}
fn wait_for_pid_exit(pid: i32, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let alive = unsafe { libc::kill(pid, 0) } == 0;
if !alive {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
#[test]
fn ensure_daemon_idempotent_when_already_running() {
// Use BSK_HOME to isolate.
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(&home).unwrap();
// Start a daemon manually first.
let out = Command::new(bsk_bin())
.args(["daemon", "start", "--port", "0", "--daemon-idle", "60s"])
.env("BSK_HOME", &home)
.env("RUST_LOG", "warn")
.output()
.unwrap();
assert!(out.status.success());
let info_path = home.join("daemon.json");
let info: serde_json::Value =
serde_json::from_slice(&std::fs::read(&info_path).unwrap()).unwrap();
let pid_before = info["pid"].as_u64().unwrap() as i32;
// Now another `bsk daemon start` is invoked. Because the lock is
// held, the spawned child should exit quickly and the existing
// daemon should keep its pid.
let _ = Command::new(bsk_bin())
.args(["daemon", "start", "--port", "0", "--daemon-idle", "60s"])
.env("BSK_HOME", &home)
.env("RUST_LOG", "warn")
.output()
.unwrap();
let info_after: serde_json::Value =
serde_json::from_slice(&std::fs::read(&info_path).unwrap()).unwrap();
let pid_after = info_after["pid"].as_u64().unwrap() as i32;
assert_eq!(pid_before, pid_after, "daemon pid should not change");
// Clean up.
let _ = Command::new(bsk_bin())
.args(["daemon", "stop"])
.env("BSK_HOME", &home)
.output();
assert!(wait_for_pid_exit(pid_before, Duration::from_secs(5)));
}
@@ -0,0 +1,142 @@
//! Review I1: `browser.list` and `multiple_browsers_online.error.data
//! .browsers` must agree on order. Both surfaces now route through
//! `snapshot_status_entries(...)` which sorts by `connected_at_ms`
//! ascending with `instance_id` as a deterministic tiebreaker.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use bsk::daemon::browsers::{
BrowserClient, BrowserId, BrowserSink, Pending, next_browser_generation,
};
use bsk::daemon::{self, DaemonConfig};
use bsk::ipc_client::IpcClient;
use bsk_protocol::system::BrowserStatusEntry;
use bsk_protocol::{ErrorCode, Method};
use rand::Rng;
use serde::Deserialize;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
fn tempfile_path(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let mut rng = rand::thread_rng();
let suffix: String = (0..8)
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
.collect();
p.push(format!("{prefix}-{}-{suffix}.sock", std::process::id()));
p
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-browser-list");
let handle = daemon::run(config, Some(sock.clone())).await.unwrap();
(handle, sock)
}
fn fake_client(id: &str, label: &str, connected_at_ms: i64) -> Arc<BrowserClient> {
let (tx, _rx) = mpsc::unbounded_channel::<bsk_protocol::Frame>();
Arc::new(BrowserClient {
id: BrowserId(id.into()),
browser_name: "chrome".into(),
browser_version: "131.0".into(),
extension_version: "0.1.0-dev.0".into(),
extension_protocol_version: "1.0".into(),
label: label.into(),
sink: BrowserSink { tx },
pending: Mutex::new(Pending::default()),
generation: next_browser_generation(),
connected_at_ms,
version_skew: false,
})
}
#[derive(Debug, Deserialize)]
struct ListReply {
browsers: Vec<BrowserStatusEntry>,
}
#[tokio::test]
async fn browser_list_and_multiple_browsers_error_share_stable_ordering() {
// We inject three clients into the registry in *reverse* order of
// their `connected_at_ms`. The `BrowserRegistry` itself is a
// HashMap so insert order is not enough to guarantee anything;
// before review I1, `browser.list` returned the HashMap snapshot
// order (essentially random), while `multiple_browsers_online`
// already sorted via `snapshot_status_entries`. The two surfaces
// could (and did) diverge.
let (handle, sock) = spawn_daemon().await;
let state = handle.state();
state.browsers.insert(fake_client("zeta", "Zeta", 3_000));
state.browsers.insert(fake_client("alpha", "Alpha", 1_000));
state.browsers.insert(fake_client("mu", "Mu", 2_000));
let mut ipc = IpcClient::connect(&sock).await.unwrap();
// 1. `browser.list` must come back sorted by `connected_at_ms`.
let list: ListReply = ipc
.call::<(), _>(
"browser-list-1",
Method::BrowserList,
None,
Duration::from_secs(2),
)
.await
.unwrap()
.expect("browser.list rpc succeeded");
let list_ids: Vec<String> = list
.browsers
.iter()
.map(|b| b.instance_id.clone())
.collect();
assert_eq!(
list_ids,
vec!["alpha".to_string(), "mu".to_string(), "zeta".to_string()],
"browser.list must sort by connected_at_ms ascending (review I1)"
);
// 2. The `multiple_browsers_online` error data carries the same
// list in the same order.
#[derive(serde::Serialize)]
struct StartParams {
browser_instance_id: Option<String>,
}
let outcome = ipc
.call::<_, serde_json::Value>(
"sess-start-multi",
Method::SessionStart,
Some(StartParams {
browser_instance_id: None,
}),
Duration::from_secs(2),
)
.await
.unwrap();
let err = outcome.expect_err("session.start must fail when many browsers are online");
assert_eq!(err.code, ErrorCode::MultipleBrowsersOnline);
let data = err.data.expect("multiple_browsers_online must carry data");
let browsers = data
.get("browsers")
.and_then(|v| v.as_array())
.expect("data.browsers is an array");
let err_ids: Vec<String> = browsers
.iter()
.filter_map(|item| {
item.get("instance_id")
.and_then(|v| v.as_str())
.map(str::to_string)
})
.collect();
assert_eq!(
err_ids, list_ids,
"multiple_browsers_online and browser.list must agree on order (review I1)"
);
handle.shutdown().await;
}
+779
View File
@@ -0,0 +1,779 @@
//! M10.2: end-to-end coverage of cancel forwarding from CLI → daemon
//! → extension. The fake extension never replies to a slow tool;
//! instead it waits for the daemon's `cancel` frame, then answers the
//! original RPC with `cancelled` so the CLI helper resolves promptly.
mod support;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use bsk::daemon::{self, DaemonConfig};
use bsk::ipc_client::IpcClient;
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
use bsk_protocol::tools::SessionStartResult;
use bsk_protocol::{
BrowserPeerInfo, CancelParams, CancelResult, ErrorCode, Frame, Method, RequestFrame,
ResponseBody, ResponseFrame, RpcError,
};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use serde_json::json;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
use support::{
wait_for_abort_registered, wait_for_inflight_forwarded, wait_until,
};
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
fn tempfile_path(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let mut rng = rand::thread_rng();
let suffix: String = (0..8)
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
.collect();
p.push(format!("{prefix}-{}-{suffix}.sock", std::process::id()));
p
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-cancel");
let handle = daemon::run(config, Some(sock.clone())).await.unwrap();
(handle, sock)
}
async fn connect_ext(
addr: std::net::SocketAddr,
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let url = format!("ws://{addr}/");
let req = Request::builder()
.method("GET")
.uri(&url)
.header("Host", addr.to_string())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.header("Origin", origin)
.body(())
.unwrap();
let (ws, _resp) = tokio_tungstenite::connect_async(req).await.unwrap();
ws
}
async fn handshake_as_ext(
ws: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> HandshakeResult {
let params = HandshakeParams {
client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(),
instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo {
name: "chrome".into(),
version: "131.0".into(),
},
label: "Test".into(),
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()),
};
let req = RequestFrame {
id: "hs".into(),
method: Method::SystemHandshake,
params: Some(serde_json::to_value(params).unwrap()),
};
ws.send(Message::Text(serde_json::to_string(&req).unwrap()))
.await
.unwrap();
let resp = ws.next().await.unwrap().unwrap();
let text = match resp {
Message::Text(t) => t,
_ => panic!(),
};
let resp: ResponseFrame = serde_json::from_str(&text).unwrap();
match resp.body {
ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(),
ResponseBody::Err(e) => panic!("handshake rejected: {e:?}"),
}
}
#[tokio::test]
async fn wait_ms_cancel_resolves_locally_via_abort_registry() {
// Sanity check that the M9.3 wait_ms path still answers the
// existing AbortRegistry-based cancel surface (regression).
let (handle, sock) = spawn_daemon().await;
let mut ipc = IpcClient::connect(&sock).await.unwrap();
// Send a long wait_ms with a known rpc_id we can reference.
let wait_id = "wait-cancel-1";
#[derive(serde::Serialize)]
struct WaitParams {
duration_ms: u64,
}
let mut sender = IpcClient::connect(&sock).await.unwrap();
let waiter = tokio::spawn(async move {
sender
.call_with_id::<WaitParams, serde_json::Value>(
wait_id.into(),
Method::ToolWaitMs,
Some(WaitParams {
duration_ms: 60_000,
}),
Duration::from_secs(5),
)
.await
});
let state = handle.state();
wait_for_abort_registered(&state, &wait_id.into()).await;
let cancel: CancelResult = ipc
.call(
"cancel-1",
Method::Cancel,
Some(CancelParams {
rpc_id: wait_id.into(),
}),
Duration::from_secs(2),
)
.await
.unwrap()
.expect("cancel rpc must succeed");
assert!(
cancel.cancelled,
"wait_ms registered locally; cancel must trip the AbortRegistry"
);
let outcome = waiter.await.unwrap().unwrap();
let err = outcome.expect_err("wait_ms should resolve as cancelled");
assert_eq!(err.code, ErrorCode::Cancelled);
handle.shutdown().await;
}
#[tokio::test]
async fn cancel_forwards_to_extension_when_tool_is_inflight() {
// Spin up daemon, attach a fake extension that:
// * answers `tool.session_start` immediately with a fake window id.
// * NEVER answers `tool.snapshot` until it sees a `cancel` frame
// from the daemon, at which point it replies to the original
// `tool.snapshot` with the structured `cancelled` error.
//
// The CLI side issues the cancel through a fresh IPC connection
// (mimicking the SIGINT helper) and must observe `cancelled` from
// the original snapshot RPC.
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
let ws = Arc::new(tokio::sync::Mutex::new(ws));
let ws_clone = Arc::clone(&ws);
let responder = tokio::spawn(async move {
// ws_to_snapshot maps the WS-side rpc_id of the still-pending
// tool.snapshot call so we can echo it back after we observe
// the daemon's cancel frame.
let mut pending_snapshot: Option<String> = None;
loop {
let next = {
let mut g = ws_clone.lock().await;
g.next().await
};
let msg = match next {
Some(Ok(m)) => m,
_ => break,
};
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let frame: Frame = match serde_json::from_str(&text) {
Ok(f) => f,
Err(_) => continue,
};
if let Frame::Request(req) = frame {
match req.method {
Method::ToolSessionStart => {
let result = SessionStartResult {
agent_window_id: Some(1),
};
let reply = ResponseFrame {
id: req.id,
body: ResponseBody::Ok(serde_json::to_value(result).unwrap()),
};
let mut g = ws_clone.lock().await;
g.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
}
Method::ToolSnapshot => {
// Stash the WS rpc_id; reply only after cancel arrives.
pending_snapshot = Some(req.id);
}
Method::Cancel => {
// The daemon forwarded our cancel — answer the
// original snapshot now with `cancelled`.
let target = req
.params
.as_ref()
.and_then(|v| v.get("rpc_id"))
.and_then(|v| v.as_str())
.map(str::to_string);
let snapshot_id = pending_snapshot.take();
if let (Some(target), Some(snap)) = (target, snapshot_id)
&& target == snap
{
let reply = ResponseFrame {
id: snap,
body: ResponseBody::Err(RpcError {
code: ErrorCode::Cancelled,
message: "snapshot aborted by daemon cancel".into(),
data: None,
}),
};
let mut g = ws_clone.lock().await;
g.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
}
}
_ => {}
}
}
}
});
// Open one IPC client for the snapshot caller and another for the
// cancel sender — the SIGINT helper does the same in production.
let mut snap_ipc = IpcClient::connect(&sock).await.unwrap();
let mut cancel_ipc = IpcClient::connect(&sock).await.unwrap();
// Start a session through the daemon's CLI-facing path.
#[derive(serde::Serialize)]
struct StartParams {
browser_instance_id: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct StartReply {
session_id: String,
}
let start: StartReply = snap_ipc
.call(
"sess-1",
Method::SessionStart,
Some(StartParams {
browser_instance_id: None,
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("session.start succeeded");
// Drive the slow `tool.snapshot` on its own connection so we can
// race a cancel through the cancel_ipc handle.
let snapshot_id = "snap-cancel-1".to_string();
let snapshot_id_clone = snapshot_id.clone();
let session_id = start.session_id.clone();
let snapshot_task = tokio::spawn(async move {
snap_ipc
.call_with_id::<_, serde_json::Value>(
snapshot_id_clone,
Method::ToolSnapshot,
Some(json!({"session_id": session_id})),
Duration::from_secs(10),
)
.await
});
let state = handle.state();
wait_for_inflight_forwarded(&state, &snapshot_id).await;
// Send the cancel.
let cancel: CancelResult = cancel_ipc
.call(
"cancel-c1",
Method::Cancel,
Some(CancelParams {
rpc_id: snapshot_id.clone(),
}),
Duration::from_secs(3),
)
.await
.unwrap()
.expect("cancel rpc succeeded");
assert!(
cancel.cancelled,
"cancel must hit the inflight entry and forward to extension"
);
let outcome = tokio::time::timeout(Duration::from_secs(5), snapshot_task)
.await
.expect("snapshot did not resolve")
.unwrap()
.unwrap();
let err = outcome.expect_err("snapshot must surface cancelled");
assert_eq!(err.code, ErrorCode::Cancelled);
responder.abort();
handle.shutdown().await;
}
/// Process-wide hook serialiser: every test that flips the
/// `__set_promote_delay_for_tests` static grabs this Mutex first so a
/// parallel `cargo test --test cancel_forwarding` cannot read a stale
/// delay set by a different test case.
static PROMOTE_HOOK_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// RAII guard that arms the inflight test hook for the lifetime of a
/// single integration test. Resets the hook to `0` on drop so a
/// pre-fix race window does not leak into unrelated tests.
struct PromoteDelayHook {
_lock: std::sync::MutexGuard<'static, ()>,
}
impl PromoteDelayHook {
fn enable(delay: Duration) -> Self {
let lock = PROMOTE_HOOK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
bsk::daemon::inflight::__set_promote_delay_for_tests(delay);
Self { _lock: lock }
}
}
impl Drop for PromoteDelayHook {
fn drop(&mut self) {
bsk::daemon::inflight::__set_promote_delay_for_tests(Duration::from_millis(0));
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cancel_arriving_during_promote_critical_section_keeps_request_cancel_invariants() {
// Round 2 C1 fault-injection: widen the pre-fix race window by
// making `promote_to_forwarded_with` sleep 200ms BEFORE acquiring
// the entry's inner lock (via `__set_promote_delay_for_tests`).
// That sleep recreates the exact window the bug lived in: the old
// `promote_to_forwarded` read the cancel atomic, then locked the
// inner Mutex; a cancel landing between those two steps used to
// snapshot None/None, trip the AbortToken, and report "queued"
// — the IPC handler would skip forwarding a WS cancel even though
// the worker was about to commit the promotion and emit the WS
// request, so the CLI got synthesised `cancelled` while the
// extension still ran the side-effecting tool. With the hook
// armed, a concurrent cancel reliably wedges itself into that
// window.
//
// After the fix, the cancelled flag lives under the inner Mutex
// and is checked there, so cancel and promote serialise through
// the lock. The two possible outcomes under fault injection are
// both safe:
// * cancel-first (the common case here, since cancel arrives
// ~50ms in while promote is still sleeping pre-lock):
// cancel acquires the inner lock first, sets cancelled,
// returns snapshot None/None → the IPC handler skips WS
// forwarding; promote later locks, observes cancelled,
// returns `Cancelled`, and the dispatch closure NEVER runs
// → no WS request frame leaves the daemon.
// * promote-first (only if cancel-IPC takes longer than the
// sleep to round-trip): snapshot reaches the extension, then
// a WS cancel frame follows for the same rpc_id, preserving
// "request-before-cancel" wire order.
//
// The invariant under either winner: the extension's tool.snapshot
// and cancel counts MUST match. Pre-fix this assertion would have
// tripped (snapshot=1, cancel=0) for the cancel-first case.
let _hook = PromoteDelayHook::enable(Duration::from_millis(200));
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
use std::sync::atomic::{AtomicUsize, Ordering as AOrdering};
let ws = Arc::new(tokio::sync::Mutex::new(ws));
let snapshots_seen = Arc::new(AtomicUsize::new(0));
let cancels_seen = Arc::new(AtomicUsize::new(0));
let snapshots_clone = Arc::clone(&snapshots_seen);
let cancels_clone = Arc::clone(&cancels_seen);
let ws_clone = Arc::clone(&ws);
let responder = tokio::spawn(async move {
let mut pending_snapshot: Option<String> = None;
loop {
let next = {
let mut g = ws_clone.lock().await;
g.next().await
};
let msg = match next {
Some(Ok(m)) => m,
_ => break,
};
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let frame: Frame = match serde_json::from_str(&text) {
Ok(f) => f,
Err(_) => continue,
};
if let Frame::Request(req) = frame {
match req.method {
Method::ToolSessionStart => {
let result = SessionStartResult {
agent_window_id: Some(1),
};
let reply = ResponseFrame {
id: req.id,
body: ResponseBody::Ok(serde_json::to_value(result).unwrap()),
};
let mut g = ws_clone.lock().await;
g.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
}
Method::ToolSnapshot => {
snapshots_clone.fetch_add(1, AOrdering::SeqCst);
pending_snapshot = Some(req.id);
}
Method::Cancel => {
cancels_clone.fetch_add(1, AOrdering::SeqCst);
let target = req
.params
.as_ref()
.and_then(|v| v.get("rpc_id"))
.and_then(|v| v.as_str())
.map(str::to_string);
let snapshot_id = pending_snapshot.take();
if let (Some(target), Some(snap)) = (target, snapshot_id)
&& target == snap
{
let reply = ResponseFrame {
id: snap,
body: ResponseBody::Err(RpcError {
code: ErrorCode::Cancelled,
message: "snapshot aborted by daemon cancel".into(),
data: None,
}),
};
let mut g = ws_clone.lock().await;
g.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
}
}
_ => {}
}
}
}
});
let mut snap_ipc = IpcClient::connect(&sock).await.unwrap();
let mut cancel_ipc = IpcClient::connect(&sock).await.unwrap();
#[derive(serde::Serialize)]
struct StartParams {
browser_instance_id: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct StartReply {
session_id: String,
}
let start: StartReply = snap_ipc
.call(
"sess-r2c1",
Method::SessionStart,
Some(StartParams {
browser_instance_id: None,
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("session.start succeeded");
let session_id = start.session_id.clone();
let snapshot_id = "snap-r2c1-race".to_string();
let snapshot_id_clone = snapshot_id.clone();
let snap_session = session_id.clone();
let snapshot_task = tokio::spawn(async move {
snap_ipc
.call_with_id::<_, serde_json::Value>(
snapshot_id_clone,
Method::ToolSnapshot,
Some(json!({"session_id": snap_session})),
Duration::from_secs(10),
)
.await
});
// Land cancel ~50ms in, while the worker is still parked inside
// the 200ms-wide promote critical section. The cancel attempt
// must serialise on the entry's inner Mutex; with the pre-fix
// race window this is exactly where a queued snapshot could trip
// the AbortToken and skip WS forwarding.
tokio::time::sleep(Duration::from_millis(50)).await;
let cancel: CancelResult = cancel_ipc
.call(
"cancel-r2c1-race",
Method::Cancel,
Some(CancelParams {
rpc_id: snapshot_id.clone(),
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("cancel rpc succeeded");
assert!(cancel.cancelled, "cancel rpc must report cancelled=true");
// CLI side of the snapshot RPC must resolve as Cancelled.
let snap_outcome = tokio::time::timeout(Duration::from_secs(8), snapshot_task)
.await
.expect("snapshot did not resolve")
.unwrap()
.unwrap();
let snap_err = snap_outcome.expect_err("snapshot must surface cancelled");
assert_eq!(snap_err.code, ErrorCode::Cancelled);
let snapshots_clone = Arc::clone(&snapshots_seen);
let cancels_clone = Arc::clone(&cancels_seen);
wait_until(
"extension snapshot/cancel counters to match",
Duration::from_secs(2),
|| {
snapshots_clone.load(AOrdering::SeqCst) == cancels_clone.load(AOrdering::SeqCst)
},
)
.await;
let snapshots = snapshots_seen.load(AOrdering::SeqCst);
let cancels = cancels_seen.load(AOrdering::SeqCst);
assert_eq!(
snapshots, cancels,
"extension must observe matching tool.snapshot / cancel counts \
(round 2 C1): snapshots={snapshots}, cancels={cancels}"
);
responder.abort();
handle.shutdown().await;
}
#[tokio::test]
async fn cancel_for_unknown_rpc_id_returns_false() {
let (handle, sock) = spawn_daemon().await;
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let cancel: CancelResult = ipc
.call(
"cancel-ghost",
Method::Cancel,
Some(CancelParams {
rpc_id: "nope".into(),
}),
Duration::from_secs(2),
)
.await
.unwrap()
.expect("cancel rpc succeeded");
assert!(!cancel.cancelled);
handle.shutdown().await;
}
#[tokio::test]
async fn concurrent_tool_call_returns_session_busy_while_slow_rpc_inflight() {
// With session busy fast-fail, a second tool RPC submitted while
// the worker is occupied returns `session_busy` immediately
// instead of queuing behind the slow call. Cancel still unblocks
// the in-flight RPC through the existing forwarded-cancel path.
use std::sync::atomic::{AtomicUsize, Ordering as AOrdering};
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
let ws = Arc::new(tokio::sync::Mutex::new(ws));
let snapshots_seen = Arc::new(AtomicUsize::new(0));
let snapshots_seen_clone = Arc::clone(&snapshots_seen);
let ws_clone = Arc::clone(&ws);
let responder = tokio::spawn(async move {
let mut pending_snapshot: Option<String> = None;
loop {
let next = {
let mut g = ws_clone.lock().await;
g.next().await
};
let msg = match next {
Some(Ok(m)) => m,
_ => break,
};
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let frame: Frame = match serde_json::from_str(&text) {
Ok(f) => f,
Err(_) => continue,
};
if let Frame::Request(req) = frame {
match req.method {
Method::ToolSessionStart => {
let result = SessionStartResult {
agent_window_id: Some(1),
};
let reply = ResponseFrame {
id: req.id,
body: ResponseBody::Ok(serde_json::to_value(result).unwrap()),
};
let mut g = ws_clone.lock().await;
g.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
}
Method::ToolSnapshot => {
snapshots_seen_clone.fetch_add(1, AOrdering::SeqCst);
pending_snapshot = Some(req.id);
}
Method::Cancel => {
let target = req
.params
.as_ref()
.and_then(|v| v.get("rpc_id"))
.and_then(|v| v.as_str())
.map(str::to_string);
let snapshot_id = pending_snapshot.take();
if let (Some(target), Some(snap)) = (target, snapshot_id)
&& target == snap
{
let reply = ResponseFrame {
id: snap,
body: ResponseBody::Err(RpcError {
code: ErrorCode::Cancelled,
message: "snapshot aborted by daemon cancel".into(),
data: None,
}),
};
let mut g = ws_clone.lock().await;
g.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
}
}
_ => {}
}
}
}
});
let mut slow_ipc = IpcClient::connect(&sock).await.unwrap();
let mut busy_ipc = IpcClient::connect(&sock).await.unwrap();
let mut cancel_ipc = IpcClient::connect(&sock).await.unwrap();
#[derive(serde::Serialize)]
struct StartParams {
browser_instance_id: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct StartReply {
session_id: String,
}
let start: StartReply = slow_ipc
.call(
"sess-cq",
Method::SessionStart,
Some(StartParams {
browser_instance_id: None,
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("session.start succeeded");
let session_id = start.session_id.clone();
let slow_id = "snap-slow".to_string();
let slow_session = session_id.clone();
let slow_handle = tokio::spawn(async move {
slow_ipc
.call_with_id::<_, serde_json::Value>(
slow_id,
Method::ToolSnapshot,
Some(json!({"session_id": slow_session})),
Duration::from_secs(10),
)
.await
});
let snapshots_seen_for_wait = Arc::clone(&snapshots_seen);
wait_until(
"slow snapshot forwarded to extension",
Duration::from_secs(2),
|| snapshots_seen_for_wait.load(AOrdering::SeqCst) >= 1,
)
.await;
let busy_id = "snap-busy".to_string();
let busy_session = session_id.clone();
let busy_outcome = tokio::time::timeout(
Duration::from_millis(200),
busy_ipc.call_with_id::<_, serde_json::Value>(
busy_id,
Method::ToolSnapshot,
Some(json!({"session_id": busy_session})),
Duration::from_secs(10),
),
)
.await
.expect("busy RPC should fast-fail immediately")
.unwrap();
let busy_err = busy_outcome.expect_err("concurrent snapshot must fast-fail as session_busy");
assert_eq!(busy_err.code, ErrorCode::Timeout);
assert_eq!(
busy_err
.data
.as_ref()
.and_then(|d| d.get("reason"))
.and_then(|v| v.as_str()),
Some(bsk::rpc_reason::SESSION_BUSY)
);
let cancel_slow: CancelResult = cancel_ipc
.call(
"cancel-slow",
Method::Cancel,
Some(CancelParams {
rpc_id: "snap-slow".into(),
}),
Duration::from_secs(3),
)
.await
.unwrap()
.expect("cancel slow rpc succeeds");
assert!(cancel_slow.cancelled);
let slow_outcome = tokio::time::timeout(Duration::from_secs(5), slow_handle)
.await
.expect("slow snapshot did not resolve")
.unwrap()
.unwrap();
let slow_err = slow_outcome.expect_err("slow snapshot must surface cancelled");
assert_eq!(slow_err.code, ErrorCode::Cancelled);
assert_eq!(
snapshots_seen.load(AOrdering::SeqCst),
1,
"only the slow snapshot must reach the extension"
);
responder.abort();
handle.shutdown().await;
}
+111
View File
@@ -0,0 +1,111 @@
//! Smoke tests for clap-derive command parsing.
use std::time::Duration;
use bsk::cli::daemon::{DaemonCmd, parse_duration};
use bsk::cli::navigate::NavigateCmd;
use bsk::{Cli, Command};
use clap::Parser;
fn parse(args: &[&str]) -> Cli {
Cli::try_parse_from(args).expect("clap parse should succeed")
}
#[test]
fn parses_daemon_start_with_defaults() {
let cli = parse(&["bsk", "daemon", "start"]);
let Command::Daemon(DaemonCmd::Start(args)) = cli.command else {
panic!("expected daemon start subcommand");
};
assert!(args.port.is_none());
assert!(!args.foreground);
assert_eq!(args.resolved_port(), 52800);
assert_eq!(args.resolved_daemon_idle(), Duration::from_secs(600));
}
#[test]
fn parses_daemon_start_with_flags() {
let cli = parse(&[
"bsk",
"daemon",
"start",
"--foreground",
"--port",
"52900",
"--daemon-idle",
"2s",
"--session-idle",
"30s",
]);
let Command::Daemon(DaemonCmd::Start(args)) = cli.command else {
panic!("expected daemon start subcommand");
};
assert!(args.foreground);
assert_eq!(args.resolved_port(), 52900);
assert_eq!(args.resolved_daemon_idle(), Duration::from_secs(2));
assert_eq!(args.resolved_session_idle(), Duration::from_secs(30));
}
#[test]
fn parses_daemon_stop_and_restart() {
let cli = parse(&["bsk", "daemon", "stop"]);
assert!(matches!(cli.command, Command::Daemon(DaemonCmd::Stop)));
let cli = parse(&["bsk", "daemon", "restart", "--foreground"]);
let Command::Daemon(DaemonCmd::Restart(args)) = cli.command else {
panic!("expected daemon restart subcommand");
};
assert!(args.foreground);
}
#[test]
fn parses_top_level_status_and_doctor() {
let cli = parse(&["bsk", "status"]);
assert!(matches!(cli.command, Command::Status));
let cli = parse(&["bsk", "doctor"]);
assert!(matches!(cli.command, Command::Doctor));
}
#[test]
fn parses_install_skill_subcommand() {
let cli = parse(&["bsk", "install-skill", "--list"]);
assert!(matches!(cli.command, Command::InstallSkill(_)));
}
#[test]
fn duration_parser_accepts_units() {
assert_eq!(parse_duration("750ms").unwrap(), Duration::from_millis(750));
assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
}
#[test]
fn parses_nested_navigate_back_and_forward() {
let cli = parse(&["bsk", "navigate", "back", "--session", "s1"]);
let Command::Navigate(cmd) = cli.command else {
panic!("expected navigate command");
};
assert!(matches!(cmd.command, Some(NavigateCmd::Back(_))));
let cli = parse(&["bsk", "navigate", "forward", "--session", "s1"]);
let Command::Navigate(cmd) = cli.command else {
panic!("expected navigate command");
};
assert!(matches!(cmd.command, Some(NavigateCmd::Forward(_))));
}
#[test]
fn parses_click_count_alias() {
let cli = parse(&["bsk", "click", "@e1", "--session", "s1", "--count", "2"]);
let Command::Click(args) = cli.command else {
panic!("expected click command");
};
assert_eq!(args.click_count, 2);
}
#[test]
fn rejects_zero_click_count() {
assert!(
Cli::try_parse_from(["bsk", "click", "@e1", "--session", "s1", "--count", "0"]).is_err()
);
}
+228
View File
@@ -0,0 +1,228 @@
//! Spawn `bsk daemon start` (default detached mode), wait for daemon.json,
//! then `bsk daemon stop` and verify the daemon is gone.
#![cfg(unix)]
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use tempfile::TempDir;
fn bsk_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_bsk"))
}
fn run(home: &std::path::Path, args: &[&str]) -> std::process::Output {
Command::new(bsk_bin())
.env("BSK_HOME", home)
.env("RUST_LOG", "warn")
.args(args)
.output()
.expect("spawn bsk command")
}
fn wait_for_pid_exit(pid: i32, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let alive = unsafe { libc::kill(pid, 0) } == 0;
if !alive {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
#[test]
fn detached_start_and_stop_round_trip() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(&home).unwrap();
// Use port 0 so concurrent test runs (or any existing daemon on
// 52800) don't collide with us; the daemon writes the chosen port
// back into daemon.json.
let out = run(
&home,
&["daemon", "start", "--port", "0", "--daemon-idle", "60s"],
);
assert!(
out.status.success(),
"daemon start failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// Daemon.json should now exist with a live pid.
let info_path = home.join("daemon.json");
assert!(info_path.exists(), "daemon.json should be written");
let info: serde_json::Value =
serde_json::from_slice(&std::fs::read(&info_path).unwrap()).unwrap();
let pid = info["pid"].as_u64().expect("pid number") as i32;
assert!(pid > 0);
// Stop the daemon.
let out = run(&home, &["daemon", "stop"]);
assert!(
out.status.success(),
"daemon stop failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
wait_for_pid_exit(pid, Duration::from_secs(5)),
"daemon pid {pid} should disappear after stop"
);
assert!(
!home.join("daemon.json").exists(),
"daemon.json should be cleaned up after stop"
);
}
#[test]
fn stop_when_no_daemon_running_is_noop() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(&home).unwrap();
let out = run(&home, &["daemon", "stop"]);
assert!(out.status.success(), "stop should succeed when no daemon");
}
#[test]
fn stop_refuses_live_pid_that_is_not_verified_daemon() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(home.join("run")).unwrap();
let mut child = Command::new("sleep")
.arg("30")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn controlled non-daemon child");
let info = serde_json::json!({
"pid": child.id(),
"sock_path": home.join("run").join("not-a-daemon.sock"),
"ws_port": 0,
"version": env!("CARGO_PKG_VERSION"),
"started_at_epoch_secs": 1
});
std::fs::write(
home.join("daemon.json"),
serde_json::to_vec_pretty(&info).unwrap(),
)
.unwrap();
let out = run(&home, &["daemon", "stop"]);
assert!(
!out.status.success(),
"stop should refuse unverified daemon.json"
);
assert!(
child.try_wait().unwrap().is_none(),
"non-daemon child must not be terminated"
);
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn start_with_conflicting_port_refuses_existing_daemon() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(&home).unwrap();
let out = run(
&home,
&["daemon", "start", "--port", "0", "--daemon-idle", "60s"],
);
assert!(
out.status.success(),
"daemon start failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let info_path = home.join("daemon.json");
let info: serde_json::Value =
serde_json::from_slice(&std::fs::read(&info_path).unwrap()).unwrap();
let pid = info["pid"].as_u64().expect("pid number") as i32;
let existing_port = info["ws_port"].as_u64().expect("ws_port") as u16;
let conflicting_port = if existing_port == 52801 { 52802 } else { 52801 };
let conflicting_port = conflicting_port.to_string();
let out = run(&home, &["daemon", "start", "--port", &conflicting_port]);
assert!(
!out.status.success(),
"start should fail when an existing daemon has different config"
);
assert!(
unsafe { libc::kill(pid, 0) } == 0,
"existing daemon should remain alive"
);
let _ = run(&home, &["daemon", "stop"]);
assert!(wait_for_pid_exit(pid, Duration::from_secs(5)));
}
#[test]
fn restart_propagates_stop_error_and_does_not_run_start() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(home.join("run")).unwrap();
let mut child = Command::new("sleep")
.arg("30")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn controlled non-daemon child");
let info = serde_json::json!({
"pid": child.id(),
"sock_path": home.join("run").join("not-a-daemon.sock"),
"ws_port": 0,
"version": env!("CARGO_PKG_VERSION"),
"started_at_epoch_secs": 1
});
std::fs::write(
home.join("daemon.json"),
serde_json::to_vec_pretty(&info).unwrap(),
)
.unwrap();
let out = run(&home, &["daemon", "restart"]);
assert!(
!out.status.success(),
"restart should fail when stop phase fails"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("restart failed during stop phase")
|| stderr.contains("restart failed during stop phase".to_lowercase().as_str()),
"restart error should mention stop phase: {stderr}"
);
// The decoy must still be alive — start should never have run.
assert!(
child.try_wait().unwrap().is_none(),
"decoy child must not be terminated"
);
// daemon.json should still point to the decoy (start did not run);
// also confirm no new daemon was actually started by checking that
// the recorded pid is still the decoy.
let after: serde_json::Value =
serde_json::from_slice(&std::fs::read(home.join("daemon.json")).unwrap()).unwrap();
assert_eq!(
after["pid"].as_u64().unwrap(),
child.id() as u64,
"daemon.json should be untouched after failed restart"
);
let _ = child.kill();
let _ = child.wait();
}
+35
View File
@@ -0,0 +1,35 @@
//! Smoke-test the §3.1 exit code mapping through the real binary.
#![cfg(unix)]
use std::path::PathBuf;
use std::process::Command;
fn bsk_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_bsk"))
}
#[test]
fn clap_usage_error_maps_to_user_error_exit_code() {
// §3.1 maps argument/usage failures to the user-error bucket.
let out = Command::new(bsk_bin())
.args(["definitely-not-a-subcommand"])
.output()
.unwrap();
assert!(!out.status.success());
assert_eq!(out.status.code(), Some(1));
}
#[test]
fn version_flag_prints_semver_and_exits_zero() {
let out = Command::new(bsk_bin())
.args(["--version"])
.output()
.unwrap();
assert!(out.status.success());
let body = String::from_utf8(out.stdout).unwrap();
assert!(
body.trim_end().starts_with("bsk "),
"expected `bsk <version>` line, got: {body:?}"
);
}
+67
View File
@@ -0,0 +1,67 @@
//! Smoke-test the foreground daemon: spawn the bin, wait for the heartbeat,
//! send SIGTERM (Unix) / CTRL_BREAK (Windows), and verify a clean exit.
#![cfg(unix)]
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use tempfile::TempDir;
fn bsk_bin() -> std::path::PathBuf {
let bin = env!("CARGO_BIN_EXE_bsk");
std::path::PathBuf::from(bin)
}
#[test]
fn foreground_daemon_starts_and_stops() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(&home).unwrap();
let mut child = Command::new(bsk_bin())
.args([
"daemon",
"start",
"--foreground",
"--port",
"0",
"--daemon-idle",
"60s",
])
.env("BSK_HOME", &home)
.env("RUST_LOG", "info")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn bsk daemon start --foreground");
// Give the process a generous moment to come up and install signal
// handlers (cargo test on cold caches can stagger spawn significantly).
std::thread::sleep(Duration::from_millis(1500));
assert!(
child.try_wait().expect("try_wait").is_none(),
"daemon should still be running"
);
let pid = child.id() as i32;
// SAFETY: sending SIGTERM to our own child.
unsafe {
let _ = libc::kill(pid, libc::SIGTERM);
}
let started = Instant::now();
let status = loop {
if let Some(status) = child.try_wait().expect("try_wait after SIGTERM") {
break status;
}
if started.elapsed() > Duration::from_secs(5) {
let _ = child.kill();
panic!("daemon did not exit within 5s of SIGTERM");
}
std::thread::sleep(Duration::from_millis(50));
};
assert!(
status.success() || status.code() == Some(0),
"daemon exited with non-success status: {status:?}"
);
}
+304
View File
@@ -0,0 +1,304 @@
//! M10.4: end-to-end coverage for the daemon WS handshake's
//! version-compatibility decision tree (Reject / Skew / Ok).
use std::path::PathBuf;
use std::time::Duration;
use bsk::daemon::{self, DaemonConfig};
use bsk::ipc_client::IpcClient;
use bsk_protocol::system::{HandshakeParams, HandshakeResult, StatusResult};
use bsk_protocol::{BrowserPeerInfo, ErrorCode, Method, RequestFrame, ResponseBody, ResponseFrame};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
fn tempfile_path(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let mut rng = rand::thread_rng();
let suffix: String = (0..8)
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
.collect();
p.push(format!("{prefix}-{}-{suffix}.sock", std::process::id()));
p
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-handshake");
let handle = daemon::run(config, Some(sock.clone())).await.unwrap();
(handle, sock)
}
async fn open_ws(
addr: std::net::SocketAddr,
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let url = format!("ws://{addr}/");
let req = Request::builder()
.method("GET")
.uri(&url)
.header("Host", addr.to_string())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.header("Origin", origin)
.body(())
.unwrap();
let (ws, _resp) = tokio_tungstenite::connect_async(req).await.unwrap();
ws
}
async fn send_handshake(
ws: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
protocol_version: &str,
app_version: &str,
) -> ResponseFrame {
send_handshake_with_floors(ws, protocol_version, app_version, None, None).await
}
async fn send_handshake_with_floors(
ws: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
protocol_version: &str,
app_version: &str,
peer_min_compatible_peer: Option<&str>,
peer_min_compatible_protocol: Option<&str>,
) -> ResponseFrame {
let params = HandshakeParams {
client: "browser-skill-extension".into(),
version: app_version.parse().unwrap(),
protocol_version: protocol_version.into(),
instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo {
name: "chrome".into(),
version: "131.0".into(),
},
label: "Test".into(),
min_compatible_peer: peer_min_compatible_peer.map(|s| s.parse().unwrap()),
min_compatible_protocol: peer_min_compatible_protocol.map(String::from),
};
let req = RequestFrame {
id: "hs".into(),
method: Method::SystemHandshake,
params: Some(serde_json::to_value(params).unwrap()),
};
ws.send(Message::Text(serde_json::to_string(&req).unwrap()))
.await
.unwrap();
let resp = ws.next().await.unwrap().unwrap();
let text = match resp {
Message::Text(t) => t,
Message::Close(_) => panic!("connection closed before reply"),
other => panic!("unexpected ws frame {other:?}"),
};
serde_json::from_str(&text).unwrap()
}
#[tokio::test]
async fn handshake_ok_when_protocol_matches() {
let (handle, _sock) = spawn_daemon().await;
let mut ws = open_ws(handle.ws_addr()).await;
let resp = send_handshake(&mut ws, "1.0", env!("CARGO_PKG_VERSION")).await;
let result: HandshakeResult = match resp.body {
ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(),
ResponseBody::Err(e) => panic!("expected ok handshake, got {e:?}"),
};
assert_eq!(result.protocol_version, "1.0");
assert_eq!(
result
.min_compatible_peer
.as_ref()
.map(ToString::to_string)
.as_deref(),
Some("0.0.0")
);
assert_eq!(
result.min_compatible_protocol.as_deref(),
Some("1.0"),
"daemon must advertise protocol floor for new extensions"
);
handle.shutdown().await;
}
#[tokio::test]
async fn handshake_ok_when_app_versions_differ_but_protocol_matches() {
let (handle, _sock) = spawn_daemon().await;
let mut ws = open_ws(handle.ws_addr()).await;
let resp =
send_handshake_with_floors(&mut ws, "1.0", "9.9.9", Some("0.0.0"), Some("1.0")).await;
match resp.body {
ResponseBody::Ok(_) => {}
other => panic!("expected ok when protocol matches, got {other:?}"),
}
handle.shutdown().await;
}
#[tokio::test]
async fn handshake_skew_when_protocol_minor_differs() {
let (handle, _sock) = spawn_daemon().await;
let mut ws = open_ws(handle.ws_addr()).await;
let resp = send_handshake_with_floors(
&mut ws,
"1.1",
env!("CARGO_PKG_VERSION"),
Some("0.0.0"),
Some("1.0"),
)
.await;
match resp.body {
ResponseBody::Ok(_) => {}
other => panic!("minor protocol drift should warn-but-allow, got {other:?}"),
}
let state = handle.state();
let client = state
.browsers
.get(&bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into()))
.expect("browser registered");
assert!(client.version_skew);
handle.shutdown().await;
}
#[tokio::test]
async fn handshake_rejected_on_protocol_major_mismatch() {
let (handle, _sock) = spawn_daemon().await;
let mut ws = open_ws(handle.ws_addr()).await;
let resp = send_handshake(&mut ws, "2.0", env!("CARGO_PKG_VERSION")).await;
match resp.body {
ResponseBody::Err(e) => {
assert_eq!(e.code, ErrorCode::VersionTooOld);
assert!(e.message.contains("protocol major mismatch"));
assert!(e.data.is_some(), "reject must carry structured data");
}
other => panic!("expected reject, got {other:?}"),
}
handle.shutdown().await;
}
#[tokio::test]
async fn handshake_rejected_when_peer_protocol_major_is_below_daemon() {
// Protocol "0.9" is below daemon major 1 — rejects before minor/floor checks.
let (handle, _sock) = spawn_daemon().await;
let mut ws = open_ws(handle.ws_addr()).await;
let resp = send_handshake(&mut ws, "0.9", env!("CARGO_PKG_VERSION")).await;
match resp.body {
ResponseBody::Err(e) => {
assert_eq!(e.code, ErrorCode::VersionTooOld);
assert!(
e.message.contains("protocol major mismatch"),
"message: {}",
e.message
);
}
other => panic!("expected reject, got {other:?}"),
}
handle.shutdown().await;
}
#[tokio::test]
async fn handshake_legacy_ext_without_protocol_floor_still_ok() {
let (handle, _sock) = spawn_daemon().await;
let mut ws = open_ws(handle.ws_addr()).await;
let resp = send_handshake_with_floors(
&mut ws,
"1.0",
env!("CARGO_PKG_VERSION"),
Some("0.1.0"),
None,
)
.await;
match resp.body {
ResponseBody::Ok(_) => {}
other => panic!("legacy ext (no min_compatible_protocol) should connect, got {other:?}"),
}
handle.shutdown().await;
}
#[tokio::test]
async fn status_surfaces_version_skew_for_skewed_browser() {
use std::sync::Mutex;
use tokio::sync::mpsc;
let (handle, sock) = spawn_daemon().await;
let state = handle.state();
let (tx, _rx) = mpsc::unbounded_channel::<bsk_protocol::Frame>();
let client = std::sync::Arc::new(bsk::daemon::browsers::BrowserClient {
id: bsk::daemon::browsers::BrowserId("skew-only-test".into()),
browser_name: "chrome".into(),
browser_version: "131.0".into(),
extension_version: "9.9.9".into(),
extension_protocol_version: "1.1".into(),
label: "Older".into(),
sink: bsk::daemon::browsers::BrowserSink { tx },
pending: Mutex::new(bsk::daemon::browsers::Pending::default()),
generation: bsk::daemon::browsers::next_browser_generation(),
connected_at_ms: 0,
version_skew: true,
});
state.browsers.insert(client);
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let status: StatusResult = ipc
.call::<(), _>(
"status-1",
Method::SystemStatus,
None,
Duration::from_secs(2),
)
.await
.unwrap()
.unwrap();
let skew = status
.version_skew_browsers
.iter()
.find(|s| s.instance_id == "skew-only-test")
.expect("status must list our skew client");
assert_eq!(skew.client_protocol_version, "1.1");
assert_eq!(skew.server_protocol_version, "1.0");
assert_eq!(skew.client_version, "9.9.9");
let entry = status
.browsers
.iter()
.find(|b| b.instance_id == "skew-only-test")
.expect("status must include the skewed browser");
assert!(entry.version_skew, "BrowserStatusEntry must carry the flag");
handle.shutdown().await;
}
#[tokio::test]
async fn handshake_rejects_when_local_below_peer_min_compatible_protocol() {
let (handle, _sock) = spawn_daemon().await;
let mut ws = open_ws(handle.ws_addr()).await;
let resp = send_handshake_with_floors(
&mut ws,
"1.0",
env!("CARGO_PKG_VERSION"),
Some("0.0.0"),
Some("99.0.0"),
)
.await;
match resp.body {
ResponseBody::Err(e) => {
assert_eq!(e.code, ErrorCode::VersionTooOld);
assert!(
e.message.contains("local protocol")
&& e.message.contains("min_compatible_protocol"),
"reject reason: {}",
e.message
);
}
other => panic!("expected reject, got {other:?}"),
}
handle.shutdown().await;
}
+107
View File
@@ -0,0 +1,107 @@
//! Verify the daemon self-exits after `--daemon-idle` elapses.
#![cfg(unix)]
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, Instant};
use tempfile::TempDir;
use tokio::net::UnixStream;
fn bsk_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_bsk"))
}
fn wait_for_pid_exit(pid: i32, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let alive = unsafe { libc::kill(pid, 0) } == 0;
if !alive {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
#[test]
fn daemon_self_exits_after_idle_timeout() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(&home).unwrap();
let out = Command::new(bsk_bin())
.env("BSK_HOME", &home)
.env("RUST_LOG", "warn")
.args(["daemon", "start", "--port", "0", "--daemon-idle", "2s"])
.output()
.expect("spawn bsk daemon start");
assert!(
out.status.success(),
"daemon start failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let info_path = home.join("daemon.json");
let info: serde_json::Value =
serde_json::from_slice(&std::fs::read(&info_path).unwrap()).unwrap();
let pid = info["pid"].as_u64().expect("pid number") as i32;
assert!(
wait_for_pid_exit(pid, Duration::from_secs(6)),
"daemon should self-exit within 6s of starting with --daemon-idle=2s"
);
// daemon.json should be cleaned up on graceful shutdown.
assert!(
!info_path.exists(),
"daemon.json should be removed after idle exit"
);
}
#[test]
fn daemon_stays_alive_while_ipc_connection_is_open() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(&home).unwrap();
let out = Command::new(bsk_bin())
.env("BSK_HOME", &home)
.env("RUST_LOG", "warn")
.args(["daemon", "start", "--port", "0", "--daemon-idle", "2s"])
.output()
.expect("spawn bsk daemon start");
assert!(
out.status.success(),
"daemon start failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let info_path = home.join("daemon.json");
let info: serde_json::Value =
serde_json::from_slice(&std::fs::read(&info_path).unwrap()).unwrap();
let pid = info["pid"].as_u64().expect("pid number") as i32;
let sock = info["sock_path"].as_str().expect("sock path").to_string();
let rt = tokio::runtime::Runtime::new().unwrap();
let stream = rt
.block_on(UnixStream::connect(sock))
.expect("connect to daemon IPC");
std::thread::sleep(Duration::from_secs(3));
assert!(
unsafe { libc::kill(pid, 0) } == 0,
"daemon should stay alive while IPC connection is open"
);
drop(stream);
std::thread::sleep(Duration::from_millis(500));
assert!(
unsafe { libc::kill(pid, 0) } == 0,
"daemon should restart the idle clock when the last IPC connection closes"
);
assert!(
wait_for_pid_exit(pid, Duration::from_secs(6)),
"daemon should self-exit after the IPC connection closes"
);
}
+81
View File
@@ -0,0 +1,81 @@
//! End-to-end round trip through `info::write` / `info::read` honoring
//! the `BSK_HOME` env var.
use std::sync::Mutex;
use bsk::daemon::info::{self, DaemonInfo};
use bsk::daemon::paths::{self, BSK_HOME_ENV};
use tempfile::TempDir;
fn env_guard() -> &'static Mutex<()> {
static GUARD: Mutex<()> = Mutex::new(());
&GUARD
}
fn with_temp_home<F: FnOnce()>(f: F) {
let _lock = env_guard().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
unsafe {
std::env::set_var(BSK_HOME_ENV, tmp.path().join("bsk"));
}
f();
unsafe {
std::env::remove_var(BSK_HOME_ENV);
}
drop(tmp);
}
#[test]
fn read_returns_none_before_any_write() {
with_temp_home(|| {
let _home = paths::ensure_bsk_home().unwrap();
assert!(info::read().unwrap().is_none());
});
}
#[test]
fn write_then_read_round_trip() {
with_temp_home(|| {
let home = paths::ensure_bsk_home().unwrap();
let info = DaemonInfo::now(
std::process::id(),
home.join("run").join("daemon.sock"),
52810,
env!("CARGO_PKG_VERSION"),
);
info::write(&info).unwrap();
let parsed = info::read().unwrap().expect("info should exist");
assert_eq!(parsed, info);
});
}
#[test]
fn read_valid_filters_stale_pids() {
with_temp_home(|| {
let home = paths::ensure_bsk_home().unwrap();
let stale = DaemonInfo::now(
0x7fff_fffe,
home.join("run").join("daemon.sock"),
52810,
"stale",
);
info::write(&stale).unwrap();
assert!(
info::read_valid().unwrap().is_none(),
"read_valid must filter out a stale pid"
);
});
}
#[test]
fn remove_is_idempotent() {
with_temp_home(|| {
let _home = paths::ensure_bsk_home().unwrap();
info::remove().unwrap();
let info = DaemonInfo::now(std::process::id(), "/tmp/x".into(), 52810, "x");
info::write(&info).unwrap();
info::remove().unwrap();
info::remove().unwrap();
assert!(info::read().unwrap().is_none());
});
}
+52
View File
@@ -0,0 +1,52 @@
//! Tests for `bsk install-skill` parsing and harness helpers.
use bsk::skill_install::{HarnessId, interactive_candidates, parse_harness_id};
use bsk::{Cli, Command};
use clap::Parser;
#[test]
fn parses_install_skill_flags() {
let cli = Cli::try_parse_from([
"bsk",
"install-skill",
"--harness",
"cursor",
"--harness",
"codex-internal",
"-y",
"--force",
])
.expect("parse install-skill");
let Command::InstallSkill(args) = cli.command else {
panic!("expected install-skill");
};
assert_eq!(args.harness, vec!["cursor", "codex-internal"]);
assert!(args.yes);
assert!(args.force);
}
#[test]
fn parse_harness_aliases_include_codebody() {
assert_eq!(parse_harness_id("codebody").unwrap(), HarnessId::Codebuddy);
}
#[test]
fn interactive_candidates_only_include_detected() {
let reports = vec![
sample_report(HarnessId::Cursor, true),
sample_report(HarnessId::Codex, false),
];
let candidates = interactive_candidates(&reports);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].id, HarnessId::Cursor);
}
fn sample_report(id: HarnessId, detected: bool) -> bsk::skill_install::HarnessReport {
bsk::skill_install::HarnessReport {
id,
skills_dir: std::path::PathBuf::from("/tmp/skills"),
detected,
detection_detail: None,
installed: false,
}
}
+47
View File
@@ -0,0 +1,47 @@
//! End-to-end IPC client ↔ server round trip.
#![cfg(unix)]
use std::time::Duration;
use bsk::daemon::ipc::{bind, default_ping_handler, serve};
use bsk::ipc_client::Client;
use bsk_protocol::{Method, PingParams, PingResult};
use tempfile::TempDir;
use tokio::runtime::Runtime;
#[test]
fn ping_through_typed_client() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let tmp = TempDir::new().unwrap();
let sock = tmp.path().join("daemon.sock");
let listener = bind(&sock).await.expect("bind");
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(serve(
listener,
default_ping_handler(),
|| {},
|| {},
|| {},
async move {
let _ = rx.await;
},
));
let mut client = Client::connect_path(sock.clone()).await.expect("connect");
let outcome = client
.call::<_, PingResult>(
Method::SystemPing,
&PingParams::default(),
Duration::from_secs(2),
)
.await
.expect("call");
let result = outcome.expect("ping should succeed");
assert!(result.pong);
let _ = tx.send(());
let _ = server.await;
});
}
+74
View File
@@ -0,0 +1,74 @@
//! End-to-end IPC ping smoke test using the public daemon::ipc API.
#![cfg(unix)]
use bsk::daemon::ipc::{bind, default_ping_handler, serve};
use bsk_protocol::{Frame, Method, RequestFrame, ResponseBody};
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::runtime::Runtime;
use tokio::sync::oneshot;
fn make_request_line(id: &str, method: Method) -> String {
let frame = Frame::Request(RequestFrame {
id: id.into(),
method,
params: None,
});
let mut s = serde_json::to_string(&frame).unwrap();
s.push('\n');
s
}
#[test]
fn ipc_ping_pong_over_uds() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let tmp = TempDir::new().unwrap();
let sock = tmp.path().join("daemon.sock");
let listener = bind(&sock).await.expect("bind");
let (tx, rx) = oneshot::channel::<()>();
let server = tokio::spawn(serve(
listener,
default_ping_handler(),
|| {},
|| {},
|| {},
async move {
let _ = rx.await;
},
));
let stream = UnixStream::connect(&sock).await.expect("connect");
let (read, mut write) = stream.into_split();
write
.write_all(make_request_line("ping-1", Method::SystemPing).as_bytes())
.await
.unwrap();
write.flush().await.unwrap();
let mut reader = BufReader::new(read);
let mut buf = String::new();
reader.read_line(&mut buf).await.unwrap();
let frame: Frame = serde_json::from_str(buf.trim_end()).unwrap();
match frame {
Frame::Response(resp) => {
assert_eq!(resp.id, "ping-1");
match resp.body {
ResponseBody::Ok(v) => {
assert_eq!(v, serde_json::json!({ "pong": true }));
}
other => panic!("unexpected body {other:?}"),
}
}
other => panic!("unexpected frame {other:?}"),
}
drop(write);
drop(reader);
let _ = tx.send(());
let _ = server.await;
});
}
+56
View File
@@ -0,0 +1,56 @@
//! Verify the daemon lock is exclusive: a second acquire in the same
//! process must fail while the first is held, and pid-alive helpers work.
use std::sync::Mutex;
use bsk::daemon::lockfile::{acquire, pid_alive};
use bsk::daemon::paths::BSK_HOME_ENV;
use tempfile::TempDir;
fn env_guard() -> &'static Mutex<()> {
static GUARD: Mutex<()> = Mutex::new(());
&GUARD
}
fn with_temp_home<F: FnOnce()>(f: F) {
let _lock = env_guard().lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
unsafe {
std::env::set_var(BSK_HOME_ENV, tmp.path().join("bsk"));
}
f();
unsafe {
std::env::remove_var(BSK_HOME_ENV);
}
drop(tmp);
}
#[test]
fn second_acquire_in_same_process_fails() {
with_temp_home(|| {
let _first = acquire().expect("first acquire");
let second = acquire();
assert!(
second.is_err(),
"second acquire should fail while first is held"
);
drop(_first);
let third = acquire().expect("third acquire after release");
drop(third);
});
}
#[test]
fn pid_alive_for_current_process() {
let me = std::process::id();
assert!(pid_alive(me));
}
#[test]
fn pid_alive_for_nonexistent_pid() {
// A pid that's almost certainly not in use (max u32 is way beyond
// typical kernel pid limits, but we pick a high-ish value to be
// conservative). On Unix kill(0) returns ESRCH for unknown pids.
let highly_improbable: u32 = 0x7fff_fffe;
assert!(!pid_alive(highly_improbable));
}
+85
View File
@@ -0,0 +1,85 @@
//! Verify the daemon emits a daily-rotated log file and `bsk logs`
//! surfaces its contents.
#![cfg(unix)]
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, Instant};
use tempfile::TempDir;
fn bsk_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_bsk"))
}
fn wait_for_pid_exit(pid: i32, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let alive = unsafe { libc::kill(pid, 0) } == 0;
if !alive {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
#[test]
fn daemon_writes_log_and_bsk_logs_prints_them() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(&home).unwrap();
let out = Command::new(bsk_bin())
.args(["daemon", "start", "--port", "0", "--daemon-idle", "60s"])
.env("BSK_HOME", &home)
.env("RUST_LOG", "info")
.output()
.unwrap();
assert!(out.status.success());
// Give the daemon a moment to flush at least one log line.
std::thread::sleep(Duration::from_millis(400));
let logs = Command::new(bsk_bin())
.args(["logs", "-n", "50"])
.env("BSK_HOME", &home)
.output()
.expect("bsk logs");
assert!(logs.status.success());
let body = String::from_utf8(logs.stdout).unwrap();
assert!(
body.contains("daemon ready") || body.contains("ipc server listening"),
"logs should include at least one daemon line: {body}"
);
let first_json_line = body
.lines()
.find(|line| !line.trim().is_empty())
.expect("at least one log line");
serde_json::from_str::<serde_json::Value>(first_json_line)
.expect("daemon logs should be JSON Lines");
// Cleanup.
let info: serde_json::Value =
serde_json::from_slice(&std::fs::read(home.join("daemon.json")).unwrap()).unwrap();
let pid = info["pid"].as_u64().unwrap() as i32;
let _ = Command::new(bsk_bin())
.args(["daemon", "stop"])
.env("BSK_HOME", &home)
.output();
assert!(wait_for_pid_exit(pid, Duration::from_secs(5)));
}
#[test]
fn bsk_logs_handles_empty_home_gracefully() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(&home).unwrap();
let logs = Command::new(bsk_bin())
.args(["logs"])
.env("BSK_HOME", &home)
.output()
.expect("bsk logs");
assert!(logs.status.success());
}
+574
View File
@@ -0,0 +1,574 @@
//! M6.5 integration: per-session serial dispatch queue.
//!
//! Properties exercised:
//! 1. Inside one session, only one `tool.*` dispatch is active at a time;
//! concurrent callers fast-fail with `session_busy`, while later calls
//! succeed after the active command completes.
//! 2. Two sessions on the same browser run in parallel and never block
//! each other — releasing one session's gate must not unblock the
//! other's response.
mod support;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use bsk::daemon::queue::DispatchError;
use bsk::daemon::{self, DaemonConfig};
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
use bsk_protocol::tools::{SessionStartParams, SessionStartResult};
use bsk_protocol::{
BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame,
};
use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use serde::Deserialize;
use serde_json::json;
use tokio::net::TcpListener;
use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
fn tempfile_path(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let mut rng = rand::thread_rng();
let suffix: String = (0..8)
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
.collect();
p.push(format!("{prefix}-{}-{suffix}.sock", std::process::id()));
p
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let sock = tempfile_path("bsk-test-queue");
let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone()))
.await
.unwrap();
(handle, sock)
}
async fn connect_ext(
addr: std::net::SocketAddr,
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let url = format!("ws://{addr}/");
let req = Request::builder()
.method("GET")
.uri(&url)
.header("Host", addr.to_string())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.header("Origin", origin)
.body(())
.unwrap();
let (ws, _resp) = tokio_tungstenite::connect_async(req).await.unwrap();
ws
}
async fn handshake_as_ext(
ws: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> HandshakeResult {
let params = HandshakeParams {
client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(),
instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo {
name: "chrome".into(),
version: "131.0".into(),
},
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()),
label: "Test".into(),
};
let req = RequestFrame {
id: "hs".into(),
method: Method::SystemHandshake,
params: Some(serde_json::to_value(params).unwrap()),
};
ws.send(Message::Text(serde_json::to_string(&req).unwrap()))
.await
.unwrap();
let resp = ws.next().await.unwrap().unwrap();
let text = match resp {
Message::Text(t) => t,
_ => panic!("expected text frame"),
};
let resp: ResponseFrame = serde_json::from_str(&text).unwrap();
match resp.body {
ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(),
ResponseBody::Err(e) => panic!("handshake rejected: {e:?}"),
}
}
type Ws =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
/// Drive a fake extension WS that:
/// * auto-replies to `tool.session_start` / `tool.session_stop` (so the
/// M5 round-trip works without test interaction),
/// * for every other request, forwards it through `requests_tx` and
/// awaits a reply payload from `replies_rx`,
/// * uses split sink/stream so the reader can park on `.next()` without
/// holding any writer-side mutex (avoiding a self-deadlock when the
/// test driver wants to push a reply).
async fn run_fake_extension(
ws: Ws,
next_window_id: Arc<Mutex<i64>>,
requests_tx: mpsc::UnboundedSender<(String, String)>, // (rpc_id, payload tag)
mut replies_rx: mpsc::UnboundedReceiver<(String, serde_json::Value)>, // (rpc_id, body)
) {
let (writer, reader) = ws.split();
let writer: Arc<Mutex<SplitSink<Ws, Message>>> = Arc::new(Mutex::new(writer));
let mut reader: SplitStream<Ws> = reader;
let reader_task = {
let writer = Arc::clone(&writer);
let next_window_id = Arc::clone(&next_window_id);
tokio::spawn(async move {
while let Some(Ok(msg)) = reader.next().await {
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let frame: Frame = match serde_json::from_str(&text) {
Ok(f) => f,
Err(_) => continue,
};
if let Frame::Request(req) = frame {
match req.method {
Method::ToolSessionStart => {
let _: SessionStartParams =
serde_json::from_value(req.params.clone().unwrap()).unwrap();
let id = {
let mut g = next_window_id.lock().await;
let v = *g;
*g += 1;
v
};
let reply = ResponseFrame {
id: req.id.clone(),
body: ResponseBody::Ok(
serde_json::to_value(SessionStartResult {
agent_window_id: Some(id),
})
.unwrap(),
),
};
let mut w = writer.lock().await;
w.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
}
Method::ToolSessionStop => {
let reply = ResponseFrame {
id: req.id.clone(),
body: ResponseBody::Ok(json!({})),
};
let mut w = writer.lock().await;
w.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
}
_ => {
let tag = req
.params
.as_ref()
.and_then(|p| p.get("tag"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let _ = requests_tx.send((req.id.clone(), tag));
}
}
}
}
})
};
let replies_task = tokio::spawn(async move {
while let Some((rpc_id, body)) = replies_rx.recv().await {
let frame = ResponseFrame {
id: rpc_id,
body: ResponseBody::Ok(body),
};
let mut w = writer.lock().await;
w.send(Message::Text(serde_json::to_string(&frame).unwrap()))
.await
.unwrap();
}
});
let _ = reader_task.await;
replies_task.abort();
}
#[derive(Deserialize)]
struct StartReply {
session_id: String,
}
async fn ipc_session_start(sock: &PathBuf) -> String {
let mut ipc = bsk::ipc_client::IpcClient::connect(sock).await.unwrap();
let r: StartReply = ipc
.call::<(), _>("qs", Method::SessionStart, None, Duration::from_secs(5))
.await
.unwrap()
.expect("session.start ok");
r.session_id
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn same_session_dispatches_run_after_previous_completes() {
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
let window_counter = Arc::new(Mutex::new(1_i64));
let (req_tx, mut req_rx) = mpsc::unbounded_channel::<(String, String)>();
let (reply_tx, reply_rx) = mpsc::unbounded_channel::<(String, serde_json::Value)>();
tokio::spawn(run_fake_extension(
ws,
Arc::clone(&window_counter),
req_tx,
reply_rx,
));
let session_id = ipc_session_start(&sock).await;
let state = handle.state();
// Submit jobs one at a time: each dispatch must finish (freeing the
// session busy slot) before the next is released.
let total = 5;
for i in 0..total {
let queues = Arc::clone(&state.tool_queues);
let sid = bsk::daemon::sessions::SessionId(session_id.clone());
let i = i as u32;
let dispatch_task = tokio::spawn(async move {
queues
.dispatch(
&sid,
Method::ToolTabList,
json!({"session_id": sid.0, "tag": format!("job-{i}")}),
Duration::from_secs(5),
None,
)
.await
});
let (rpc_id, tag) = tokio::time::timeout(Duration::from_secs(2), req_rx.recv())
.await
.expect("request did not reach extension")
.expect("request channel closed");
assert_eq!(tag, format!("job-{i}"));
reply_tx
.send((rpc_id, json!({"acked": true})))
.unwrap();
let r = dispatch_task.await.unwrap();
assert!(r.is_ok(), "dispatch should succeed, got {r:?}");
}
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn session_stop_fast_fails_while_tool_is_in_flight() {
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
let window_counter = Arc::new(Mutex::new(1_i64));
let (req_tx, mut req_rx) = mpsc::unbounded_channel::<(String, String)>();
let (reply_tx, reply_rx) = mpsc::unbounded_channel::<(String, serde_json::Value)>();
tokio::spawn(run_fake_extension(
ws,
Arc::clone(&window_counter),
req_tx,
reply_rx,
));
let session_id = ipc_session_start(&sock).await;
let state = handle.state();
let sid = bsk::daemon::sessions::SessionId(session_id.clone());
let tool_task = {
let queues = Arc::clone(&state.tool_queues);
let sid = sid.clone();
tokio::spawn(async move {
queues
.dispatch(
&sid,
Method::ToolSnapshot,
json!({"session_id": sid.0, "tag": "slow-tool"}),
Duration::from_secs(5),
None,
)
.await
})
};
let (tool_rpc_id, tag) = tokio::time::timeout(Duration::from_secs(2), req_rx.recv())
.await
.expect("slow tool did not reach extension")
.expect("request channel closed");
assert_eq!(tag, "slow-tool");
let stop_result = bsk::daemon::sessions::stop_session(
&state.browsers,
&state.sessions,
&state.tool_queues,
&state.session_interrupts,
&sid,
Duration::from_secs(5),
)
.await;
assert!(
matches!(stop_result, Err(bsk::daemon::sessions::StopSessionError::SessionBusy)),
"session.stop should fast-fail while a tool is active, got {stop_result:?}"
);
assert!(
state.tool_queues.is_accepting(&sid),
"session should remain accepting after busy stop rejection"
);
let late = state
.tool_queues
.dispatch(
&sid,
Method::ToolTabList,
json!({"session_id": session_id, "tag": "late-tool"}),
Duration::from_secs(1),
None,
)
.await;
assert!(
matches!(late, Err(DispatchError::SessionBusy)),
"new tools should be rejected while session is busy, got {late:?}"
);
reply_tx
.send((tool_rpc_id, json!({"snapshot": "done"})))
.unwrap();
assert!(tool_task.await.unwrap().is_ok());
bsk::daemon::sessions::stop_session(
&state.browsers,
&state.sessions,
&state.tool_queues,
&state.session_interrupts,
&sid,
Duration::from_secs(5),
)
.await
.expect("session.stop ok after tool completes");
assert!(state.sessions.get(&sid).is_none());
assert_eq!(state.tool_queues.len(), 0);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrent_same_session_dispatch_returns_session_busy_immediately() {
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
let window_counter = Arc::new(Mutex::new(1_i64));
let (req_tx, mut req_rx) = mpsc::unbounded_channel::<(String, String)>();
let (reply_tx, reply_rx) = mpsc::unbounded_channel::<(String, serde_json::Value)>();
tokio::spawn(run_fake_extension(
ws,
Arc::clone(&window_counter),
req_tx,
reply_rx,
));
let session_id = ipc_session_start(&sock).await;
let state = handle.state();
let sid = bsk::daemon::sessions::SessionId(session_id.clone());
let first_task = {
let queues = Arc::clone(&state.tool_queues);
let sid = sid.clone();
tokio::spawn(async move {
queues
.dispatch(
&sid,
Method::ToolTabList,
json!({"session_id": sid.0, "tag": "held-open"}),
Duration::from_secs(5),
None,
)
.await
})
};
let (first_rpc_id, tag) = tokio::time::timeout(Duration::from_secs(2), req_rx.recv())
.await
.expect("first tool did not reach extension")
.expect("request channel closed");
assert_eq!(tag, "held-open");
let busy = tokio::time::timeout(
Duration::from_millis(200),
state.tool_queues.dispatch(
&sid,
Method::ToolTabList,
json!({"session_id": session_id, "tag": "should-fail"}),
Duration::from_secs(5),
None,
),
)
.await
.expect("busy dispatch should fast-fail immediately");
assert!(
matches!(busy, Err(DispatchError::SessionBusy)),
"second dispatch should fast-fail, got {busy:?}"
);
let rpc = DispatchError::SessionBusy.into_rpc();
assert_eq!(rpc.code, ErrorCode::Timeout);
assert_eq!(
rpc.data.as_ref().and_then(|d| d.get("reason")).and_then(|v| v.as_str()),
Some(bsk::rpc_reason::SESSION_BUSY)
);
reply_tx
.send((first_rpc_id, json!({"acked": true})))
.unwrap();
assert!(first_task.await.unwrap().is_ok());
let after_task = {
let queues = Arc::clone(&state.tool_queues);
let sid = sid.clone();
let session_id = session_id.clone();
tokio::spawn(async move {
queues
.dispatch(
&sid,
Method::ToolTabList,
json!({"session_id": session_id, "tag": "after-first"}),
Duration::from_secs(5),
None,
)
.await
})
};
let (after_rpc_id, after_tag) = tokio::time::timeout(Duration::from_secs(2), req_rx.recv())
.await
.expect("after-first request did not reach extension")
.expect("request channel closed");
assert_eq!(after_tag, "after-first");
reply_tx
.send((after_rpc_id, json!({"acked": true})))
.unwrap();
let after = after_task.await.unwrap();
assert!(after.is_ok(), "dispatch should succeed after first completes, got {after:?}");
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dispatches_for_different_sessions_do_not_block_each_other() {
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
let window_counter = Arc::new(Mutex::new(1_i64));
let (req_tx, mut req_rx) = mpsc::unbounded_channel::<(String, String)>();
let (reply_tx, reply_rx) = mpsc::unbounded_channel::<(String, serde_json::Value)>();
tokio::spawn(run_fake_extension(
ws,
Arc::clone(&window_counter),
req_tx,
reply_rx,
));
let session_a = ipc_session_start(&sock).await;
let session_b = ipc_session_start(&sock).await;
assert_ne!(session_a, session_b);
let state = handle.state();
// Fire one job per session simultaneously. They must both arrive at
// the extension before either is replied to (proves cross-session
// parallelism).
let queues_for_a = Arc::clone(&state.tool_queues);
let sid_a = bsk::daemon::sessions::SessionId(session_a.clone());
let task_a = tokio::spawn(async move {
queues_for_a
.dispatch(
&sid_a,
Method::ToolTabList,
json!({"session_id": sid_a.0, "tag": "from-a"}),
Duration::from_secs(5),
None,
)
.await
});
let queues_for_b = Arc::clone(&state.tool_queues);
let sid_b = bsk::daemon::sessions::SessionId(session_b.clone());
let task_b = tokio::spawn(async move {
queues_for_b
.dispatch(
&sid_b,
Method::ToolTabList,
json!({"session_id": sid_b.0, "tag": "from-b"}),
Duration::from_secs(5),
None,
)
.await
});
let mut seen = HashSet::new();
let mut rpc_ids = Vec::new();
for _ in 0..2 {
let (rpc_id, tag) = tokio::time::timeout(Duration::from_secs(2), req_rx.recv())
.await
.expect("expected inbound request")
.unwrap();
seen.insert(tag);
rpc_ids.push(rpc_id);
}
assert!(
seen.contains("from-a") && seen.contains("from-b"),
"both sessions' requests must be forwarded in parallel, saw {seen:?}"
);
for id in rpc_ids {
reply_tx.send((id, json!({"acked": true}))).unwrap();
}
let _ = task_a.await.unwrap();
let _ = task_b.await.unwrap();
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dispatch_returns_session_not_found_for_unknown_session() {
let (handle, _sock) = spawn_daemon().await;
let queues = Arc::clone(&handle.state().tool_queues);
let result = queues
.dispatch(
&bsk::daemon::sessions::SessionId("zzzz".into()),
Method::ToolTabList,
json!({"session_id": "zzzz"}),
Duration::from_secs(1),
None,
)
.await;
match result {
Err(DispatchError::SessionNotFound) => {}
other => panic!("expected SessionNotFound, got {other:?}"),
}
// And the surfaced RpcError carries `not_found`.
let rpc = DispatchError::SessionNotFound.into_rpc();
assert_eq!(rpc.code, ErrorCode::NotFound);
handle.shutdown().await;
}
@@ -0,0 +1,673 @@
//! End-to-end coverage of the SessionUserInterrupt path: an
//! `EventFrame` arriving from the (fake) extension over the WS
//! channel cancels every inflight tool RPC for that session and the
//! IPC caller observes `ErrorCode::UserAborted` (NOT `Cancelled`).
//!
//! The daemon synthesises the IPC error code locally from the
//! recorded `CancelReason::UserAborted` — even if the extension's
//! cancel-reply body says `Cancelled`, the IPC peer sees
//! `UserAborted` because `cancel_session` records the reason before
//! the WS cancel forward goes out.
mod support;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use bsk::daemon::{self, DaemonConfig};
use bsk::ipc_client::IpcClient;
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
use bsk_protocol::tools::SessionStartResult;
use bsk_protocol::{
BrowserPeerInfo, ErrorCode, EventFrame, EventKind, Frame, Method, RequestFrame, ResponseBody,
ResponseFrame, RpcError,
};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use serde_json::json;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
use support::{wait_for_inflight_forwarded, wait_for_session_interrupt_pending};
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
fn tempfile_path(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let mut rng = rand::thread_rng();
let suffix: String = (0..8)
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
.collect();
p.push(format!("{prefix}-{}-{suffix}.sock", std::process::id()));
p
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-user-interrupt");
let handle = daemon::run(config, Some(sock.clone())).await.unwrap();
(handle, sock)
}
async fn connect_ext(
addr: std::net::SocketAddr,
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let url = format!("ws://{addr}/");
let req = Request::builder()
.method("GET")
.uri(&url)
.header("Host", addr.to_string())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.header("Origin", origin)
.body(())
.unwrap();
let (ws, _resp) = tokio_tungstenite::connect_async(req).await.unwrap();
ws
}
async fn handshake_as_ext(
ws: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> HandshakeResult {
let params = HandshakeParams {
client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(),
instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo {
name: "chrome".into(),
version: "131.0".into(),
},
label: "Test".into(),
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()),
};
let req = RequestFrame {
id: "hs".into(),
method: Method::SystemHandshake,
params: Some(serde_json::to_value(params).unwrap()),
};
ws.send(Message::Text(serde_json::to_string(&req).unwrap()))
.await
.unwrap();
let resp = ws.next().await.unwrap().unwrap();
let text = match resp {
Message::Text(t) => t,
_ => panic!(),
};
let resp: ResponseFrame = serde_json::from_str(&text).unwrap();
match resp.body {
ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(),
ResponseBody::Err(e) => panic!("handshake rejected: {e:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn session_user_interrupt_event_cancels_inflight_with_user_aborted() {
// 1. Spin up daemon + fake extension.
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
// 2. Set up the fake extension's responder: replies to
// tool.session_start immediately, stashes the WS rpc_id of
// tool.snapshot but does NOT reply, and on receiving a
// `cancel` frame from the daemon answers the original snapshot
// with body=Cancelled (the IPC peer should still see
// UserAborted because the daemon overrides the code from the
// inflight entry's CancelReason).
//
// Split the WS into independent read and write halves: the
// receive loop blocks on `next().await` for the entire duration
// of the test, and the main task needs to push the
// SessionUserInterrupt event INTO the same WS while that loop is
// parked. With a single Mutex over the whole stream the send
// would deadlock against the receive lock; splitting decouples
// the two directions cleanly. The `cancel_forwarding.rs` tests
// get away with the Mutex pattern because they only ever send
// from inside the receiver loop (in response to a cancel frame
// from the daemon).
let (ws_sink, mut ws_stream) = ws.split();
let ws_sink = Arc::new(tokio::sync::Mutex::new(ws_sink));
let responder_sink = Arc::clone(&ws_sink);
let responder = tokio::spawn(async move {
let mut pending_snapshot: Option<String> = None;
while let Some(next) = ws_stream.next().await {
let msg = match next {
Ok(m) => m,
Err(_) => break,
};
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let frame: Frame = match serde_json::from_str(&text) {
Ok(f) => f,
Err(_) => continue,
};
if let Frame::Request(req) = frame {
match req.method {
Method::ToolSessionStart => {
let result = SessionStartResult {
agent_window_id: Some(1),
};
let reply = ResponseFrame {
id: req.id,
body: ResponseBody::Ok(serde_json::to_value(result).unwrap()),
};
let mut g = responder_sink.lock().await;
g.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
}
Method::ToolSnapshot => {
pending_snapshot = Some(req.id);
}
Method::Cancel => {
let target = req
.params
.as_ref()
.and_then(|v| v.get("rpc_id"))
.and_then(|v| v.as_str())
.map(str::to_string);
let snapshot_id = pending_snapshot.take();
if let (Some(target), Some(snap)) = (target, snapshot_id)
&& target == snap
{
let reply = ResponseFrame {
id: snap,
body: ResponseBody::Err(RpcError {
code: ErrorCode::Cancelled,
message: "snapshot aborted by daemon cancel".into(),
data: None,
}),
};
let mut g = responder_sink.lock().await;
g.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
}
}
_ => {}
}
}
}
});
// 3. CLI side: open IPC, start session, kick off slow snapshot.
let mut snap_ipc = IpcClient::connect(&sock).await.unwrap();
let event_ws = Arc::clone(&ws_sink);
#[derive(serde::Serialize)]
struct StartParams {
browser_instance_id: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct StartReply {
session_id: String,
}
let start: StartReply = snap_ipc
.call(
"sess-1",
Method::SessionStart,
Some(StartParams {
browser_instance_id: None,
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("session.start succeeded");
let session_id = start.session_id.clone();
let snapshot_id = "snap-user-1".to_string();
let snapshot_id_clone = snapshot_id.clone();
let session_id_for_task = session_id.clone();
let snapshot_task = tokio::spawn(async move {
snap_ipc
.call_with_id::<_, serde_json::Value>(
snapshot_id_clone,
Method::ToolSnapshot,
Some(json!({"session_id": session_id_for_task})),
Duration::from_secs(10),
)
.await
});
let state = handle.state();
wait_for_inflight_forwarded(&state, &snapshot_id).await;
// 5. Fake extension EMITS the SessionUserInterrupt event over WS
// (this is what the real Extension's background SW will do
// when the user clicks the mask interrupt button).
let event = EventFrame {
event: EventKind::SessionUserInterrupt,
payload: json!({"session_id": session_id}),
};
{
let mut g = event_ws.lock().await;
g.send(Message::Text(serde_json::to_string(&event).unwrap()))
.await
.unwrap();
}
// 6. The daemon should now: trip the inflight cancel flag with
// UserAborted, emit a per-RPC cancel frame to the extension,
// and the responder above will answer the original snapshot.
// The IPC peer (CLI) should see `UserAborted` (NOT
// `Cancelled`) because the daemon synthesises the error from
// the recorded CancelReason.
let outcome = tokio::time::timeout(Duration::from_secs(5), snapshot_task)
.await
.expect("snapshot did not resolve")
.unwrap()
.unwrap();
let err = outcome.expect_err("snapshot must surface a cancellation error");
assert_eq!(
err.code,
ErrorCode::UserAborted,
"user-interrupt path must surface UserAborted, not Cancelled (got {:?})",
err
);
responder.abort();
handle.shutdown().await;
}
/// The pending-interrupt marker must reject a *new* mutating tool
/// call that arrives AFTER the user clicked stop, even when no
/// tool was inflight at click time. Under v2 single-use marker
/// semantics, the marker survives indefinitely until consumed by
/// a mutating tool call — there is no time window. This is the
/// core motivating scenario: agents spend most of their time
/// between tool calls (LLM thinking), and the stop button must
/// catch a tool dispatched arbitrarily later.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn user_interrupt_rejects_next_mutating_tool_call_when_session_was_idle() {
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
let (ws_sink, ws_stream) = ws.split();
let ws_sink = Arc::new(tokio::sync::Mutex::new(ws_sink));
// Fake extension: answer tool.session_start, observe other
// requests but never reply. We expect the daemon to reject the
// tool call BEFORE it ever forwards a tool.click frame.
let ws_sink_for_responder = Arc::clone(&ws_sink);
let observed_click_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let observed_click_count_clone = Arc::clone(&observed_click_count);
let responder = tokio::spawn(async move {
let mut ws_stream = ws_stream;
while let Some(Ok(msg)) = ws_stream.next().await {
let text = match msg {
Message::Text(t) => t,
_ => continue,
};
let frame: Frame = match serde_json::from_str(&text) {
Ok(f) => f,
Err(_) => continue,
};
if let Frame::Request(req) = frame {
if req.method == Method::ToolSessionStart {
let result = SessionStartResult {
agent_window_id: Some(1),
};
let reply = ResponseFrame {
id: req.id,
body: ResponseBody::Ok(serde_json::to_value(result).unwrap()),
};
let mut g = ws_sink_for_responder.lock().await;
g.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
} else if req.method == Method::ToolClick {
observed_click_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
// Deliberately do NOT reply — daemon should reject before
// ever forwarding to us.
let _ = req;
}
}
}
});
// CLI side: open IPC, start session.
let mut ipc = IpcClient::connect(&sock).await.unwrap();
#[derive(serde::Serialize)]
struct StartParams {
browser_instance_id: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct StartReply {
session_id: String,
}
let start: StartReply = ipc
.call(
"sess-1",
Method::SessionStart,
Some(StartParams {
browser_instance_id: None,
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("session.start succeeded");
// No tool inflight. The user clicks stop — emit the WS event.
let event = EventFrame {
event: EventKind::SessionUserInterrupt,
payload: json!({"session_id": start.session_id.clone()}),
};
{
let mut g = ws_sink.lock().await;
g.send(Message::Text(serde_json::to_string(&event).unwrap()))
.await
.unwrap();
}
let state = handle.state();
wait_for_session_interrupt_pending(&state, &start.session_id).await;
// The CLI now issues a tool.click (mutating). The daemon must
// reject it WITHOUT forwarding to the extension.
let click_outcome = ipc
.call::<_, serde_json::Value>(
"click-after-interrupt",
Method::ToolClick,
Some(json!({
"session_id": start.session_id,
"ref": "fake-ref-1",
})),
Duration::from_secs(3),
)
.await
.unwrap();
let err = click_outcome.expect_err("tool.click must be rejected");
assert_eq!(
err.code,
ErrorCode::UserAborted,
"rejected tool dispatch must surface UserAborted (got {:?})",
err
);
// Crucially: the extension never saw a tool.click frame.
assert_eq!(
observed_click_count.load(std::sync::atomic::Ordering::SeqCst),
0,
"tool.click must NOT have been forwarded to the extension"
);
responder.abort();
handle.shutdown().await;
}
/// Read-only tool calls (snapshot / get_html / waits / tab_list)
/// must pass through transparently when a pending-interrupt
/// marker is set, AND must not consume the marker. This covers
/// the design rule that the agent should be able to observe page
/// state to ask the user a coherent question.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn read_only_tool_passes_through_without_consuming_interrupt_marker() {
use futures_util::stream::StreamExt;
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
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
// — must remain 0 because the daemon should reject the click
// without forwarding.
let ws_sink_for_responder = Arc::clone(&ws_sink);
let observed_click_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let observed_click_count_clone = Arc::clone(&observed_click_count);
let responder = tokio::spawn(async move {
let mut ws_stream = ws_stream;
while let Some(Ok(msg)) = ws_stream.next().await {
let text = match msg {
tokio_tungstenite::tungstenite::protocol::Message::Text(t) => t,
_ => continue,
};
let frame: Frame = match serde_json::from_str(&text) {
Ok(f) => f,
Err(_) => continue,
};
if let Frame::Request(req) = frame {
let body = match req.method {
Method::ToolSessionStart => ResponseBody::Ok(
serde_json::to_value(SessionStartResult {
agent_window_id: Some(1),
})
.unwrap(),
),
Method::ToolSnapshot => ResponseBody::Ok(json!({"ok": true})),
Method::ToolClick => {
observed_click_count_clone
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
continue;
}
_ => continue,
};
let reply = ResponseFrame { id: req.id, body };
let mut g = ws_sink_for_responder.lock().await;
g.send(tokio_tungstenite::tungstenite::protocol::Message::Text(
serde_json::to_string(&reply).unwrap(),
))
.await
.unwrap();
}
}
});
let mut ipc = IpcClient::connect(&sock).await.unwrap();
#[derive(serde::Serialize)]
struct StartParams {
browser_instance_id: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct StartReply {
session_id: String,
}
let start: StartReply = ipc
.call(
"sess-1",
Method::SessionStart,
Some(StartParams {
browser_instance_id: None,
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("session.start succeeded");
// User clicks stop while idle.
let event = EventFrame {
event: EventKind::SessionUserInterrupt,
payload: json!({"session_id": start.session_id.clone()}),
};
{
let mut g = ws_sink.lock().await;
g.send(tokio_tungstenite::tungstenite::protocol::Message::Text(
serde_json::to_string(&event).unwrap(),
))
.await
.unwrap();
}
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.
let snapshot_outcome = ipc
.call::<_, serde_json::Value>(
"snap-after-interrupt",
Method::ToolSnapshot,
Some(json!({"session_id": start.session_id})),
Duration::from_secs(3),
)
.await
.unwrap();
assert!(
snapshot_outcome.is_ok(),
"read-only tool.snapshot must pass through transparently (got {:?})",
snapshot_outcome
);
// The mutating click that follows MUST still be rejected — the
// marker survived the snapshot.
let click_outcome = ipc
.call::<_, serde_json::Value>(
"click-after-snapshot",
Method::ToolClick,
Some(json!({
"session_id": start.session_id,
"ref": "fake-ref-2",
})),
Duration::from_secs(3),
)
.await
.unwrap();
let err = click_outcome.expect_err("tool.click must be rejected");
assert_eq!(err.code, ErrorCode::UserAborted, "got {:?}", err);
assert_eq!(
observed_click_count.load(std::sync::atomic::Ordering::SeqCst),
0,
"tool.click must NOT have been forwarded after snapshot transparency"
);
responder.abort();
handle.shutdown().await;
}
/// The pending-interrupt marker must survive an arbitrary delay
/// between user click and next mutating tool call. This is the
/// regression test for v2's core motivating bug: v1's 500ms time
/// window dropped interrupts whenever the LLM's thinking phase
/// took longer to respond than the window allowed.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn user_interrupt_marker_survives_long_delay_before_next_tool() {
use futures_util::stream::StreamExt;
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
let (ws_sink, ws_stream) = ws.split();
let ws_sink = Arc::new(tokio::sync::Mutex::new(ws_sink));
let ws_sink_for_responder = Arc::clone(&ws_sink);
let observed_click_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let observed_click_count_clone = Arc::clone(&observed_click_count);
let responder = tokio::spawn(async move {
let mut ws_stream = ws_stream;
while let Some(Ok(msg)) = ws_stream.next().await {
let text = match msg {
tokio_tungstenite::tungstenite::protocol::Message::Text(t) => t,
_ => continue,
};
let frame: Frame = match serde_json::from_str(&text) {
Ok(f) => f,
Err(_) => continue,
};
if let Frame::Request(req) = frame {
let body = match req.method {
Method::ToolSessionStart => ResponseBody::Ok(
serde_json::to_value(SessionStartResult {
agent_window_id: Some(1),
})
.unwrap(),
),
Method::ToolClick => {
observed_click_count_clone
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
continue;
}
_ => continue,
};
let reply = ResponseFrame { id: req.id, body };
let mut g = ws_sink_for_responder.lock().await;
g.send(tokio_tungstenite::tungstenite::protocol::Message::Text(
serde_json::to_string(&reply).unwrap(),
))
.await
.unwrap();
}
}
});
let mut ipc = IpcClient::connect(&sock).await.unwrap();
#[derive(serde::Serialize)]
struct StartParams {
browser_instance_id: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct StartReply {
session_id: String,
}
let start: StartReply = ipc
.call(
"sess-1",
Method::SessionStart,
Some(StartParams {
browser_instance_id: None,
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("session.start succeeded");
let event = EventFrame {
event: EventKind::SessionUserInterrupt,
payload: json!({"session_id": start.session_id.clone()}),
};
{
let mut g = ws_sink.lock().await;
g.send(tokio_tungstenite::tungstenite::protocol::Message::Text(
serde_json::to_string(&event).unwrap(),
))
.await
.unwrap();
}
// Sleep WELL beyond v1's old 500ms window. Production LLM
// thinking phases routinely run 3-10 seconds.
tokio::time::sleep(Duration::from_secs(2)).await;
let click_outcome = ipc
.call::<_, serde_json::Value>(
"click-after-long-delay",
Method::ToolClick,
Some(json!({
"session_id": start.session_id,
"ref": "fake-ref-3",
})),
Duration::from_secs(3),
)
.await
.unwrap();
let err = click_outcome.expect_err("tool.click after long delay must be rejected");
assert_eq!(err.code, ErrorCode::UserAborted);
assert_eq!(
observed_click_count.load(std::sync::atomic::Ordering::SeqCst),
0,
"tool.click must NOT have reached the extension"
);
responder.abort();
handle.shutdown().await;
}
+859
View File
@@ -0,0 +1,859 @@
//! M5.1 integration: spin up the minimal daemon + IPC, connect a fake
//! extension over WebSocket, and exercise the session.start/stop round-
//! trip through `IpcClient`.
mod support;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use bsk::daemon::{self, DaemonConfig};
use bsk::ipc_client::IpcClient;
use bsk_protocol::system::{HandshakeParams, HandshakeResult, StatusResult};
use bsk_protocol::tools::{SessionStartParams, SessionStartResult, SessionStopParams};
use bsk_protocol::{BrowserPeerInfo, Frame, Method, RequestFrame, ResponseBody, ResponseFrame};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
use support::{wait_for_browser_count, wait_for_no_sessions, wait_for_session_count};
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
fn tempfile_path(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let mut rng = rand::thread_rng();
let suffix: String = (0..8)
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
.collect();
p.push(format!("{prefix}-{}-{suffix}.sock", std::process::id()));
p
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
spawn_daemon_with_connect_wait(bsk::daemon::browsers::EXTENSION_CONNECT_WAIT).await
}
async fn spawn_daemon_with_connect_wait(connect_wait: Duration) -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let config = DaemonConfig::new(port).with_extension_connect_wait(connect_wait);
let sock = tempfile_path("bsk-test-ipc");
let handle = daemon::run(config, Some(sock.clone())).await.unwrap();
(handle, sock)
}
async fn connect_ext(
addr: std::net::SocketAddr,
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let url = format!("ws://{addr}/");
let req = Request::builder()
.method("GET")
.uri(&url)
.header("Host", addr.to_string())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.header("Origin", origin)
.body(())
.unwrap();
let (ws, _resp) = tokio_tungstenite::connect_async(req).await.unwrap();
ws
}
async fn handshake_as_ext(
ws: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> HandshakeResult {
let params = HandshakeParams {
client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(),
instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo {
name: "chrome".into(),
version: "131.0".into(),
},
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()),
label: "Test".into(),
};
let req = RequestFrame {
id: "hs".into(),
method: Method::SystemHandshake,
params: Some(serde_json::to_value(params).unwrap()),
};
ws.send(Message::Text(serde_json::to_string(&req).unwrap()))
.await
.unwrap();
let resp = ws.next().await.unwrap().unwrap();
let text = match resp {
Message::Text(t) => t,
_ => panic!(),
};
let resp: ResponseFrame = serde_json::from_str(&text).unwrap();
match resp.body {
ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(),
ResponseBody::Err(e) => panic!("handshake rejected: {e:?}"),
}
}
#[tokio::test]
async fn session_start_stop_round_trip_via_ipc() {
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
// Spawn a fake extension responder that handles tool.session_start
// and tool.session_stop. The Arc<Mutex<...>> is necessary because
// tokio_tungstenite::WebSocketStream is !Sync but the outer task and
// the IPC client thread both need to drive the same socket.
let ws = Arc::new(tokio::sync::Mutex::new(ws));
let ws_clone = Arc::clone(&ws);
let responder = tokio::spawn(async move {
loop {
let next = {
let mut g = ws_clone.lock().await;
g.next().await
};
let msg = match next {
Some(Ok(m)) => m,
_ => break,
};
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let frame: Frame = serde_json::from_str(&text).unwrap();
if let Frame::Request(req) = frame {
let reply = match req.method {
Method::ToolSessionStart => {
let _: SessionStartParams =
serde_json::from_value(req.params.clone().unwrap()).unwrap();
let result = SessionStartResult {
agent_window_id: Some(4242),
};
ResponseFrame {
id: req.id,
body: ResponseBody::Ok(serde_json::to_value(result).unwrap()),
}
}
Method::ToolSessionStop => {
let _: SessionStopParams =
serde_json::from_value(req.params.clone().unwrap()).unwrap();
ResponseFrame {
id: req.id,
body: ResponseBody::Ok(serde_json::json!({})),
}
}
_ => continue,
};
let mut g = ws_clone.lock().await;
g.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
}
}
});
let mut ipc = IpcClient::connect(&sock).await.unwrap();
#[derive(serde::Serialize)]
struct StartParams {
browser_instance_id: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct StartReply {
session_id: String,
browser_instance_id: String,
agent_window_id: Option<i64>,
}
let start: StartReply = ipc
.call(
"s-1",
Method::SessionStart,
Some(StartParams {
browser_instance_id: None,
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("session.start returned error");
assert_eq!(start.session_id.len(), 4);
assert!(start.session_id.chars().all(|c| c.is_ascii_lowercase()));
assert_eq!(start.browser_instance_id, TEST_EXT_ID);
assert_eq!(start.agent_window_id, Some(4242));
// status should reflect 1 session
let status: StatusResult = ipc
.call::<(), _>("s-2", Method::SystemStatus, None, Duration::from_secs(2))
.await
.unwrap()
.unwrap();
assert_eq!(status.sessions.len(), 1);
assert_eq!(status.browsers[0].session_count, 1);
#[derive(serde::Serialize)]
struct StopParams {
session_id: Option<String>,
all: bool,
}
#[derive(serde::Deserialize, Debug)]
struct StopReply {
stopped: Vec<String>,
}
let stop: StopReply = ipc
.call(
"s-3",
Method::SessionStop,
Some(StopParams {
session_id: Some(start.session_id.clone()),
all: false,
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("session.stop returned error");
assert_eq!(stop.stopped, vec![start.session_id]);
let status_after: StatusResult = ipc
.call::<(), _>("s-4", Method::SystemStatus, None, Duration::from_secs(2))
.await
.unwrap()
.unwrap();
assert!(status_after.sessions.is_empty());
assert_eq!(status_after.browsers[0].session_count, 0);
responder.abort();
handle.shutdown().await;
}
#[tokio::test]
async fn session_start_errors_without_browser() {
let (handle, sock) = spawn_daemon_with_connect_wait(Duration::ZERO).await;
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let result: Result<serde_json::Value, bsk_protocol::RpcError> = ipc
.call::<(), _>("s-1", Method::SessionStart, None, Duration::from_secs(2))
.await
.unwrap();
let err = result.expect_err("expected no_browser_connected error");
assert_eq!(err.code, bsk_protocol::ErrorCode::NoBrowserConnected);
handle.shutdown().await;
}
#[tokio::test]
async fn session_start_waits_for_late_extension_handshake() {
let (handle, sock) = spawn_daemon_with_connect_wait(Duration::from_millis(500)).await;
let ws_addr = handle.ws_addr();
let sock_for_ipc = sock.clone();
let start_task = tokio::spawn(async move {
let mut ipc = IpcClient::connect(&sock_for_ipc).await.unwrap();
ipc.call::<(), serde_json::Value>(
"s-late",
Method::SessionStart,
None,
Duration::from_secs(5),
)
.await
.unwrap()
});
let ws = Arc::new(tokio::sync::Mutex::new(connect_ext(ws_addr).await));
let ws_clone = Arc::clone(&ws);
let responder = tokio::spawn(async move {
loop {
let next = {
let mut g = ws_clone.lock().await;
g.next().await
};
let msg = match next {
Some(Ok(m)) => m,
_ => break,
};
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let frame: Frame = serde_json::from_str(&text).unwrap();
let Frame::Request(req) = frame else {
continue;
};
if req.method != Method::ToolSessionStart {
continue;
}
let result = SessionStartResult {
agent_window_id: Some(4242),
};
let resp = Frame::Response(ResponseFrame {
id: req.id,
body: ResponseBody::Ok(serde_json::to_value(result).unwrap()),
});
let mut g = ws_clone.lock().await;
g.send(Message::Text(serde_json::to_string(&resp).unwrap()))
.await
.unwrap();
break;
}
});
{
let mut g = ws.lock().await;
let _ = handshake_as_ext(&mut g).await;
}
start_task
.await
.expect("join")
.expect("session.start should succeed after extension connects");
responder.abort();
handle.shutdown().await;
}
/// Connect a second WebSocket extension with a distinct instance id /
/// label / origin so the daemon's registry sees two browsers.
async fn connect_second_ext(
addr: std::net::SocketAddr,
instance_id: &str,
label: &str,
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
let url = format!("ws://{addr}/");
let req = Request::builder()
.method("GET")
.uri(&url)
.header("Host", addr.to_string())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.header(
"Origin",
"chrome-extension://abcdefghijklmnoppmnolkjihgfedcba".to_string(),
)
.body(())
.unwrap();
let (mut b, _) = tokio_tungstenite::connect_async(req).await.unwrap();
let params = HandshakeParams {
client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(),
instance_id: instance_id.into(),
browser: BrowserPeerInfo {
name: "edge".into(),
version: "130".into(),
},
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()),
label: label.into(),
};
let hs = RequestFrame {
id: "hs".into(),
method: Method::SystemHandshake,
params: Some(serde_json::to_value(params).unwrap()),
};
b.send(Message::Text(serde_json::to_string(&hs).unwrap()))
.await
.unwrap();
let _ = b.next().await.unwrap();
b
}
#[tokio::test]
async fn session_start_errors_with_multiple_browsers() {
let (handle, sock) = spawn_daemon().await;
let mut a = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut a).await;
let _b = connect_second_ext(handle.ws_addr(), "second-browser", "Edge").await;
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let result: Result<serde_json::Value, bsk_protocol::RpcError> = ipc
.call::<(), _>("s-1", Method::SessionStart, None, Duration::from_secs(2))
.await
.unwrap();
let err = result.expect_err("expected multiple_browsers_online");
assert_eq!(err.code, bsk_protocol::ErrorCode::MultipleBrowsersOnline);
let data = err
.data
.as_ref()
.expect("multiple_browsers_online must include browser list in error.data");
let browsers = data
.get("browsers")
.and_then(|v| v.as_array())
.expect("error.data.browsers should be an array");
assert_eq!(browsers.len(), 2);
let entries: Vec<bsk_protocol::system::BrowserStatusEntry> = browsers
.iter()
.map(|v| serde_json::from_value(v.clone()).unwrap())
.collect();
let ids: std::collections::HashSet<&str> =
entries.iter().map(|e| e.instance_id.as_str()).collect();
assert!(ids.contains(TEST_EXT_ID));
assert!(ids.contains("second-browser"));
handle.shutdown().await;
}
#[tokio::test]
async fn session_start_with_browser_instance_id_picks_target() {
let (handle, sock) = spawn_daemon().await;
let mut a = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut a).await;
let b = connect_second_ext(handle.ws_addr(), "second-browser", "Edge").await;
// Spawn a fake responder for ext A only — we expect daemon to
// route session.start to the requested instance id, NOT to ext B.
let a = Arc::new(tokio::sync::Mutex::new(a));
let a_clone = Arc::clone(&a);
let responder = tokio::spawn(async move {
loop {
let next = {
let mut g = a_clone.lock().await;
g.next().await
};
let msg = match next {
Some(Ok(m)) => m,
_ => break,
};
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let frame: Frame = serde_json::from_str(&text).unwrap();
if let Frame::Request(req) = frame
&& req.method == Method::ToolSessionStart
{
let result = SessionStartResult {
agent_window_id: Some(123),
};
let reply = ResponseFrame {
id: req.id,
body: ResponseBody::Ok(serde_json::to_value(result).unwrap()),
};
let mut g = a_clone.lock().await;
g.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
}
}
});
#[derive(serde::Serialize)]
struct StartParams {
browser_instance_id: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct StartReply {
#[allow(dead_code)]
session_id: String,
browser_instance_id: String,
agent_window_id: Option<i64>,
}
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let reply: StartReply = ipc
.call(
"s-pick",
Method::SessionStart,
Some(StartParams {
browser_instance_id: Some(TEST_EXT_ID.into()),
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("session.start must succeed when --browser is unambiguous");
assert_eq!(reply.browser_instance_id, TEST_EXT_ID);
assert_eq!(reply.agent_window_id, Some(123));
drop(b);
responder.abort();
handle.shutdown().await;
}
#[tokio::test]
async fn session_start_with_unknown_label_returns_not_found() {
let (handle, sock) = spawn_daemon().await;
let mut a = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut a).await;
let _b = connect_second_ext(handle.ws_addr(), "second-browser", "Edge").await;
#[derive(serde::Serialize)]
struct StartParams {
browser_instance_id: Option<String>,
}
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let result: Result<serde_json::Value, bsk_protocol::RpcError> = ipc
.call(
"s-miss",
Method::SessionStart,
Some(StartParams {
browser_instance_id: Some("does-not-exist".into()),
}),
Duration::from_secs(2),
)
.await
.unwrap();
let err = result.expect_err("expected not_found");
assert_eq!(err.code, bsk_protocol::ErrorCode::NotFound);
handle.shutdown().await;
}
#[tokio::test]
async fn session_start_label_match_picks_target() {
let (handle, sock) = spawn_daemon().await;
let mut a = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut a).await;
// Test label is "Test" (set via handshake_as_ext) on ext A; ext B has "Edge".
let _b = connect_second_ext(handle.ws_addr(), "second-browser", "Edge").await;
// Fake responder for ext A only — same drill as the instance_id test.
let a = Arc::new(tokio::sync::Mutex::new(a));
let a_clone = Arc::clone(&a);
let responder = tokio::spawn(async move {
loop {
let next = {
let mut g = a_clone.lock().await;
g.next().await
};
let msg = match next {
Some(Ok(m)) => m,
_ => break,
};
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let frame: Frame = serde_json::from_str(&text).unwrap();
if let Frame::Request(req) = frame
&& req.method == Method::ToolSessionStart
{
let result = SessionStartResult {
agent_window_id: Some(456),
};
let reply = ResponseFrame {
id: req.id,
body: ResponseBody::Ok(serde_json::to_value(result).unwrap()),
};
let mut g = a_clone.lock().await;
g.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
}
}
});
#[derive(serde::Serialize)]
struct StartParams {
browser_instance_id: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct StartReply {
browser_instance_id: String,
}
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let reply: StartReply = ipc
.call(
"s-label",
Method::SessionStart,
Some(StartParams {
browser_instance_id: Some("Test".into()),
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("session.start must succeed for unique label match");
assert_eq!(reply.browser_instance_id, TEST_EXT_ID);
responder.abort();
handle.shutdown().await;
}
#[tokio::test]
async fn session_start_ambiguous_label_returns_invalid_params() {
let (handle, sock) = spawn_daemon().await;
let mut a = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut a).await;
// Both browsers share the label "Shared" — the daemon must
// surface `invalid_params` with the ambiguous instance ids in
// error.data.
let _b = connect_second_ext(handle.ws_addr(), "second-browser", "Test").await;
#[derive(serde::Serialize)]
struct StartParams {
browser_instance_id: Option<String>,
}
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let result: Result<serde_json::Value, bsk_protocol::RpcError> = ipc
.call(
"s-amb",
Method::SessionStart,
Some(StartParams {
browser_instance_id: Some("Test".into()),
}),
Duration::from_secs(2),
)
.await
.unwrap();
let err = result.expect_err("expected ambiguous label invalid_params");
assert_eq!(err.code, bsk_protocol::ErrorCode::InvalidParams);
let data = err.data.as_ref().expect("ambiguous label must carry data");
let label = data.get("label").and_then(|v| v.as_str()).unwrap();
let ids = data
.get("instance_ids")
.and_then(|v| v.as_array())
.expect("instance_ids array");
assert_eq!(label, "Test");
assert_eq!(ids.len(), 2);
handle.shutdown().await;
}
#[tokio::test]
async fn session_window_closed_event_purges_session() {
let (handle, _sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
let state = handle.state();
let session = bsk::daemon::sessions::Session {
id: bsk::daemon::sessions::SessionId("zzzz".into()),
browser_id: bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into()),
agent_window_id: Some(7),
created_at_ms: 0,
};
state.sessions.insert(session);
assert_eq!(state.sessions.len(), 1);
// Send `session.window_closed` event from the fake extension.
let event = bsk_protocol::EventFrame {
event: bsk_protocol::EventKind::SessionWindowClosed,
payload: serde_json::json!({
"session_id": "zzzz",
"reason": "user_closed_window",
}),
};
ws.send(Message::Text(
serde_json::to_string(&bsk_protocol::Frame::Event(event)).unwrap(),
))
.await
.unwrap();
wait_for_no_sessions(&state).await;
let _ = ws.close(None).await;
handle.shutdown().await;
}
#[tokio::test]
async fn browser_disconnect_purges_sessions() {
let (handle, _sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
// Manually inject a session bound to this browser to simulate the
// post-start state without bothering with the round-trip.
let state = handle.state();
let session = bsk::daemon::sessions::Session {
id: bsk::daemon::sessions::SessionId("zzzz".into()),
browser_id: bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into()),
agent_window_id: Some(7),
created_at_ms: 0,
};
state.sessions.insert(session);
assert_eq!(state.sessions.len(), 1);
let _ = ws.close(None).await;
drop(ws);
wait_for_no_sessions(&state).await;
handle.shutdown().await;
}
#[tokio::test]
async fn session_stop_self_heals_when_extension_reports_not_found() {
// After an extension SW restart the daemon still owns the session
// entry (Round 2 generation-guard) but the extension's
// SessionManager is reset and answers `not_found`. Stop must
// reconcile local state so `session.list` does not show an orphan
// (review M4/M5 round 3 I-R3-2).
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
// Inject a session bound to this browser (mimics surviving SW
// restart from the daemon's point of view).
let state = handle.state();
let session = bsk::daemon::sessions::Session {
id: bsk::daemon::sessions::SessionId("yyyy".into()),
browser_id: bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into()),
agent_window_id: Some(99),
created_at_ms: 1,
};
state.sessions.insert(session);
state
.tool_queues
.spawn(bsk::daemon::sessions::SessionId("yyyy".into()));
assert_eq!(state.sessions.len(), 1);
// Fake extension: always reply not_found to tool.session_stop.
let ws = Arc::new(tokio::sync::Mutex::new(ws));
let ws_clone = Arc::clone(&ws);
let responder = tokio::spawn(async move {
loop {
let next = {
let mut g = ws_clone.lock().await;
g.next().await
};
let msg = match next {
Some(Ok(m)) => m,
_ => break,
};
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let frame: Frame = match serde_json::from_str(&text) {
Ok(f) => f,
Err(_) => continue,
};
if let Frame::Request(req) = frame
&& req.method == Method::ToolSessionStop
{
let reply = ResponseFrame {
id: req.id,
body: ResponseBody::Err(bsk_protocol::RpcError {
code: bsk_protocol::ErrorCode::NotFound,
message: "session unknown to extension".into(),
data: None,
}),
};
let mut g = ws_clone.lock().await;
g.send(Message::Text(serde_json::to_string(&reply).unwrap()))
.await
.unwrap();
}
}
});
#[derive(serde::Serialize)]
struct StopParams {
session_id: Option<String>,
all: bool,
}
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let result: Result<serde_json::Value, bsk_protocol::RpcError> = ipc
.call(
"s-1",
Method::SessionStop,
Some(StopParams {
session_id: Some("yyyy".into()),
all: false,
}),
Duration::from_secs(3),
)
.await
.unwrap();
assert!(
result.is_ok(),
"stop must succeed when extension reports not_found (orphan reconciliation), got {result:?}"
);
assert_eq!(
state.sessions.len(),
0,
"session must be forgotten locally after extension not_found"
);
responder.abort();
handle.shutdown().await;
}
#[tokio::test]
async fn reconnect_with_same_instance_id_does_not_clobber_new_browser() {
// Spawn a daemon, connect ext A, register a session bound to it,
// then connect ext B reusing the same instance_id (mimics a SW
// restart / WS reconnect under MV3). When the OLD WS task tears
// down, the generation guard MUST keep the new entry + its
// session in the registry. Without the guard the stale cleanup
// path would call browsers.remove() + sessions.purge_browser()
// and the daemon would silently drop the live session.
let (handle, _sock) = spawn_daemon().await;
let mut ws_a = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws_a).await;
let state = handle.state();
// Pretend ext A registered a real session.
let session = bsk::daemon::sessions::Session {
id: bsk::daemon::sessions::SessionId("xxxx".into()),
browser_id: bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into()),
agent_window_id: Some(11),
created_at_ms: 1,
};
state.sessions.insert(session);
assert_eq!(state.sessions.len(), 1);
// Open a second WS that reuses TEST_EXT_ID.
let mut ws_b = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws_b).await;
// Registry now holds the newer generation under the same id.
assert_eq!(state.browsers.len(), 1);
// Tear down ext A only; the cleanup path will run but must
// observe that the registered generation no longer matches and
// leave the new entry + session alone.
let _ = ws_a.close(None).await;
drop(ws_a);
wait_for_browser_count(&state, 1).await;
wait_for_session_count(&state, 1).await;
assert_eq!(
state.browsers.len(),
1,
"new browser entry must survive old cleanup"
);
assert_eq!(
state.sessions.len(),
1,
"session must survive old browser cleanup"
);
let _ = ws_b.close(None).await;
handle.shutdown().await;
}
#[tokio::test]
async fn reserve_id_loops_until_vacant_and_caps_attempts() {
// Pre-fill the registry with a known id and verify reserve_id
// honours the existing placeholder rather than overwriting it.
let registry = std::sync::Arc::new(bsk::daemon::sessions::SessionRegistry::new());
let browser = bsk::daemon::browsers::BrowserId("collision-browser".into());
// Reserve once so the registry holds a placeholder; the returned id
// is random so we cannot assert its value, but we can assert that a
// second reservation never produces the same id.
let first = registry
.reserve_id(browser.clone(), 64, || 1)
.expect("first reservation should succeed");
let second = registry
.reserve_id(browser.clone(), 64, || 2)
.expect("second reservation should succeed");
assert_ne!(first, second, "reserve_id must avoid collisions");
assert_eq!(registry.len(), 2);
// A capped attempts budget of 0 surfaces the IdExhausted condition.
let exhausted = registry.reserve_id(browser, 0, || 3);
assert!(exhausted.is_none(), "0-attempt budget should bail out");
// Cancelling makes the slot vacant again.
registry.cancel_reservation(&first);
assert_eq!(registry.len(), 1);
}
+292
View File
@@ -0,0 +1,292 @@
//! End-to-end `bsk status --json` and `bsk doctor` tests.
#![cfg(unix)]
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::{Mutex, MutexGuard};
use std::time::Duration;
use tempfile::TempDir;
static STATUS_CMD_TEST_LOCK: Mutex<()> = Mutex::new(());
fn status_cmd_test_guard() -> MutexGuard<'static, ()> {
STATUS_CMD_TEST_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn bsk_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_bsk"))
}
fn wait_for_pid_exit(pid: i32, timeout: Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
let alive = unsafe { libc::kill(pid, 0) } == 0;
if !alive {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
#[test]
fn bsk_status_json_returns_structured_payload() {
let _guard = status_cmd_test_guard();
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(&home).unwrap();
// Auto-spawn via `bsk status` — should bring up the daemon.
let out = Command::new(bsk_bin())
.args(["--json", "status"])
.env("BSK_HOME", &home)
.env("RUST_LOG", "warn")
.output()
.expect("run bsk status");
assert!(
out.status.success(),
"bsk status failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("status --json should be valid JSON");
assert!(parsed["pid"].as_u64().unwrap() > 0);
assert!(!parsed["daemon_version"].as_str().unwrap().is_empty());
assert_eq!(parsed["protocol_version"], "1.0");
assert!(parsed["sock_path"].as_str().is_some());
assert_eq!(parsed["browsers"], serde_json::json!([]));
assert_eq!(parsed["sessions"], serde_json::json!([]));
let pid = parsed["pid"].as_u64().unwrap() as i32;
// Cleanup.
let _ = Command::new(bsk_bin())
.args(["daemon", "stop"])
.env("BSK_HOME", &home)
.output();
assert!(wait_for_pid_exit(pid, Duration::from_secs(5)));
}
#[test]
fn bsk_doctor_runs_without_running_daemon() {
let _guard = status_cmd_test_guard();
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(&home).unwrap();
// `bsk doctor` auto-spawns the daemon when none is running; use a short
// browser-connect wait so the test doesn't block for 5 s.
let out = Command::new(bsk_bin())
.args(["doctor"])
.env("BSK_HOME", &home)
.env("RUST_LOG", "warn")
.env("BSK_DOCTOR_BROWSER_WAIT_MS", "200")
.output()
.expect("run bsk doctor");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("bsk home writable"),
"doctor should mention bsk home check: {stdout}"
);
assert!(
stdout.contains("daemon running"),
"doctor should mention daemon check: {stdout}"
);
assert!(
stdout.contains("extension connected"),
"doctor should mention extension connected check: {stdout}"
);
// Clean up the daemon that was auto-spawned above.
if let Ok(bytes) = std::fs::read(home.join("daemon.json")) {
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&bytes) {
if let Some(pid) = info["pid"].as_u64() {
let _ = Command::new(bsk_bin())
.args(["daemon", "stop"])
.env("BSK_HOME", &home)
.output();
wait_for_pid_exit(pid as i32, Duration::from_secs(5));
}
}
}
}
#[test]
fn bsk_doctor_json_returns_structured_checks() {
let _guard = status_cmd_test_guard();
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(&home).unwrap();
// `bsk doctor` auto-spawns the daemon when none is running; use a short
// browser-connect wait so the test doesn't block for 5 s.
let out = Command::new(bsk_bin())
.args(["--json", "doctor"])
.env("BSK_HOME", &home)
.env("RUST_LOG", "warn")
.env("BSK_DOCTOR_BROWSER_WAIT_MS", "200")
.output()
.expect("run bsk doctor --json");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("doctor --json should be valid JSON");
let checks = parsed.as_array().expect("doctor output should be an array");
assert!(
checks
.iter()
.any(|check| check["name"] == "bsk home writable"),
"doctor JSON should include home check: {stdout}"
);
assert!(
checks
.iter()
.any(|check| check["name"] == "extension connected"),
"doctor JSON should include extension connected check: {stdout}"
);
// Clean up the daemon that was auto-spawned above.
if let Ok(bytes) = std::fs::read(home.join("daemon.json")) {
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&bytes) {
if let Some(pid) = info["pid"].as_u64() {
let _ = Command::new(bsk_bin())
.args(["daemon", "stop"])
.env("BSK_HOME", &home)
.output();
wait_for_pid_exit(pid as i32, Duration::from_secs(5));
}
}
}
}
#[test]
fn bsk_doctor_does_not_treat_live_non_daemon_pid_as_running() {
let _guard = status_cmd_test_guard();
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(home.join("run")).unwrap();
let mut child = Command::new("sleep")
.arg("30")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn controlled non-daemon child");
let info = serde_json::json!({
"pid": child.id(),
"sock_path": home.join("run").join("not-a-daemon.sock"),
"ws_port": 0,
"version": env!("CARGO_PKG_VERSION"),
"started_at_epoch_secs": 1
});
std::fs::write(
home.join("daemon.json"),
serde_json::to_vec_pretty(&info).unwrap(),
)
.unwrap();
let out = Command::new(bsk_bin())
.args(["--json", "doctor"])
.env("BSK_HOME", &home)
.env("RUST_LOG", "warn")
.output()
.expect("run bsk doctor --json");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
let checks: Vec<serde_json::Value> = serde_json::from_str(stdout.trim()).unwrap();
let daemon = checks
.iter()
.find(|check| check["name"] == "daemon running")
.expect("daemon running check");
assert_eq!(daemon["ok"], false);
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn bsk_doctor_flags_pid_mismatch_against_running_daemon() {
let _guard = status_cmd_test_guard();
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("bsk");
std::fs::create_dir_all(&home).unwrap();
// Bring up a real daemon so the socket is live and IPC works.
let out = Command::new(bsk_bin())
.args(["daemon", "start", "--port", "0", "--daemon-idle", "60s"])
.env("BSK_HOME", &home)
.env("RUST_LOG", "warn")
.output()
.expect("daemon start");
assert!(
out.status.success(),
"daemon start failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let info_path = home.join("daemon.json");
let original: serde_json::Value =
serde_json::from_slice(&std::fs::read(&info_path).unwrap()).unwrap();
let real_pid = original["pid"].as_u64().expect("pid number") as i32;
// Spawn a controlled "other" process so the rewritten pid is alive
// but is definitely not our daemon. Without an alive pid the check
// would fail for a different reason (stale-dead) than what we want.
let mut decoy = Command::new("sleep")
.arg("30")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn decoy pid");
let mut tampered = original.clone();
tampered["pid"] = serde_json::Value::from(decoy.id());
std::fs::write(&info_path, serde_json::to_vec_pretty(&tampered).unwrap()).unwrap();
let out = Command::new(bsk_bin())
.args(["--json", "doctor"])
.env("BSK_HOME", &home)
.env("RUST_LOG", "warn")
.output()
.expect("bsk doctor --json");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
let checks: Vec<serde_json::Value> = serde_json::from_str(stdout.trim()).unwrap();
let daemon = checks
.iter()
.find(|check| check["name"] == "daemon running")
.expect("daemon running check");
assert_eq!(
daemon["ok"], false,
"daemon running check should fail when daemon.json pid was tampered: {stdout}"
);
let detail = daemon["detail"].as_str().unwrap_or_default();
assert!(
detail.contains(&decoy.id().to_string()) && detail.contains(&real_pid.to_string()),
"daemon running detail should mention both pids; got {detail}"
);
// Restore the real pid so cleanup `bsk daemon stop` can find the daemon.
std::fs::write(&info_path, serde_json::to_vec_pretty(&original).unwrap()).unwrap();
let _ = decoy.kill();
let _ = decoy.wait();
let _ = Command::new(bsk_bin())
.args(["daemon", "stop"])
.env("BSK_HOME", &home)
.output();
assert!(wait_for_pid_exit(real_pid, Duration::from_secs(5)));
}
+110
View File
@@ -0,0 +1,110 @@
//! Shared synchronization helpers for IPC integration tests.
//!
//! Prefer condition-based waits over fixed `sleep` so tests proceed as soon
//! as the daemon / fake extension reaches the expected state.
#![allow(dead_code)]
use std::sync::Arc;
use std::time::{Duration, Instant};
use bsk::daemon::sessions::SessionId;
use bsk::daemon::state::DaemonState;
use bsk::daemon::queue::ToolQueueRegistry;
use bsk_protocol::RpcId;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
/// Poll `condition` until it returns `true` or `timeout` elapses.
pub async fn wait_until<F>(label: &str, timeout: Duration, mut condition: F)
where
F: FnMut() -> bool,
{
let deadline = Instant::now() + timeout;
loop {
if condition() {
return;
}
if Instant::now() >= deadline {
panic!("timed out waiting for {label}");
}
tokio::task::yield_now().await;
}
}
pub async fn wait_for_browser_count(state: &Arc<DaemonState>, expected: usize) {
wait_until(
&format!("browser count == {expected}"),
DEFAULT_TIMEOUT,
|| state.browsers.len() == expected,
)
.await;
}
pub async fn wait_for_no_sessions(state: &Arc<DaemonState>) {
wait_until("no sessions", DEFAULT_TIMEOUT, || state.sessions.is_empty()).await;
}
pub async fn wait_for_session_count(state: &Arc<DaemonState>, expected: usize) {
wait_until(
&format!("session count == {expected}"),
DEFAULT_TIMEOUT,
|| state.sessions.len() == expected,
)
.await;
}
pub async fn wait_for_inflight_registered(state: &Arc<DaemonState>, rpc_id: &RpcId) {
let id = rpc_id.clone();
wait_until(
&format!("inflight registered for {id}"),
DEFAULT_TIMEOUT,
|| state.tool_inflight.get(&id).is_some(),
)
.await;
}
pub async fn wait_for_inflight_forwarded(state: &Arc<DaemonState>, rpc_id: &RpcId) {
let id = rpc_id.clone();
wait_until(
&format!("inflight forwarded for {id}"),
DEFAULT_TIMEOUT,
|| {
state
.tool_inflight
.get(&id)
.is_some_and(|entry| entry.snapshot().ws_rpc_id.is_some())
},
)
.await;
}
pub async fn wait_for_abort_registered(state: &Arc<DaemonState>, rpc_id: &RpcId) {
let id = rpc_id.clone();
wait_until(
&format!("abort token for {id}"),
DEFAULT_TIMEOUT,
|| state.abort_registry.contains(&id),
)
.await;
}
pub async fn wait_for_session_interrupt_pending(state: &Arc<DaemonState>, session_id: &str) {
let sid = SessionId(session_id.to_string());
wait_until(
&format!("session interrupt pending for {session_id}"),
DEFAULT_TIMEOUT,
|| state.session_interrupts.is_pending(&sid),
)
.await;
}
pub async fn wait_for_session_not_accepting(queues: &Arc<ToolQueueRegistry>, sid: &SessionId) {
let sid = sid.clone();
wait_until(
&format!("session {} queue closed to new tools", sid.0),
DEFAULT_TIMEOUT,
|| !queues.is_accepting(&sid),
)
.await;
}
+433
View File
@@ -0,0 +1,433 @@
//! M6 integration: drive `tool.*` RPCs (tab_list / screenshot /
//! snapshot / get_html) through the IPC + per-session queue + fake
//! extension and assert the wire shapes line up.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
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,
};
use bsk_protocol::{
BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError,
};
use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
fn tempfile_path(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let mut rng = rand::thread_rng();
let suffix: String = (0..8)
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
.collect();
p.push(format!("{prefix}-{}-{suffix}.sock", std::process::id()));
p
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let sock = tempfile_path("bsk-test-tools");
let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone()))
.await
.unwrap();
(handle, sock)
}
type Ws =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
async fn connect_ext(addr: std::net::SocketAddr) -> Ws {
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let url = format!("ws://{addr}/");
let req = Request::builder()
.method("GET")
.uri(&url)
.header("Host", addr.to_string())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.header("Origin", origin)
.body(())
.unwrap();
let (ws, _) = tokio_tungstenite::connect_async(req).await.unwrap();
ws
}
async fn do_handshake(ws: &mut Ws) -> HandshakeResult {
let params = HandshakeParams {
client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(),
instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo {
name: "chrome".into(),
version: "131.0".into(),
},
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()),
label: "Test".into(),
};
let req = RequestFrame {
id: "hs".into(),
method: Method::SystemHandshake,
params: Some(serde_json::to_value(params).unwrap()),
};
ws.send(Message::Text(serde_json::to_string(&req).unwrap()))
.await
.unwrap();
let resp = ws.next().await.unwrap().unwrap();
let text = match resp {
Message::Text(t) => t,
_ => panic!("expected text"),
};
let frame: ResponseFrame = serde_json::from_str(&text).unwrap();
match frame.body {
ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(),
ResponseBody::Err(e) => panic!("handshake rejected: {e:?}"),
}
}
/// Fake extension that auto-replies to `tool.session_start` /
/// `tool.session_stop` and routes everything else through a closure
/// supplied by the test.
fn run_extension<F>(ws: Ws, reply: F)
where
F: Fn(&RequestFrame) -> ResponseBody + Send + Sync + 'static,
{
let (writer, reader) = ws.split();
let writer: Arc<Mutex<SplitSink<Ws, Message>>> = Arc::new(Mutex::new(writer));
let mut reader: SplitStream<Ws> = reader;
let reply = Arc::new(reply);
let mut window_id: i64 = 100;
tokio::spawn(async move {
while let Some(Ok(msg)) = reader.next().await {
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let frame: Frame = match serde_json::from_str(&text) {
Ok(f) => f,
Err(_) => continue,
};
let Frame::Request(req) = frame else { continue };
let body = match req.method {
Method::ToolSessionStart => {
let _: SessionStartParams =
serde_json::from_value(req.params.clone().unwrap()).unwrap();
let id = window_id;
window_id += 1;
ResponseBody::Ok(
serde_json::to_value(SessionStartResult {
agent_window_id: Some(id),
})
.unwrap(),
)
}
Method::ToolSessionStop => ResponseBody::Ok(json!({})),
_ => reply(&req),
};
let resp = ResponseFrame {
id: req.id.clone(),
body,
};
let mut w = writer.lock().await;
w.send(Message::Text(serde_json::to_string(&resp).unwrap()))
.await
.unwrap();
}
});
}
async fn ipc_session_start(sock: &PathBuf) -> String {
let mut ipc = IpcClient::connect(sock).await.unwrap();
#[derive(serde::Deserialize)]
struct R {
session_id: String,
}
let r: R = ipc
.call::<(), _>("s", Method::SessionStart, None, Duration::from_secs(5))
.await
.unwrap()
.expect("session.start ok");
r.session_id
}
async fn ipc_tool_call<P: serde::Serialize, R: serde::de::DeserializeOwned>(
sock: &PathBuf,
method: Method,
params: P,
) -> Result<R, RpcError> {
let mut ipc = IpcClient::connect(sock).await.unwrap();
ipc.call::<P, R>("t", method, Some(params), Duration::from_secs(15))
.await
.unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tab_list_round_trips_through_ipc_queue_and_ws() {
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| {
// Echo a representative tab list result.
assert_eq!(req.method, Method::ToolTabList);
let _: TabListParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
ResponseBody::Ok(
serde_json::to_value(TabListResult {
tabs: vec![TabInfo {
tab_id: 42,
title: Some("Example".into()),
url: Some("https://example.com/".into()),
window_id: Some(100),
active: Some(true),
scope: Some(TabScope::User),
}],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: TabListResult = ipc_tool_call(
&sock,
Method::ToolTabList,
TabListParams {
session_id: session_id.clone(),
scope: TabScope::User,
},
)
.await
.expect("tab_list ok");
assert_eq!(result.tabs.len(), 1);
assert_eq!(result.tabs[0].tab_id, 42);
assert_eq!(result.tabs[0].scope, Some(TabScope::User));
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn screenshot_returns_image_base64_with_dimensions() {
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::ToolScreenshot);
let _: ScreenshotParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
ResponseBody::Ok(
serde_json::to_value(ScreenshotResult {
image_base64: "iVBORw0KGgo=".into(),
width: 800,
height: 600,
format: "png".into(),
tab_id: 7,
dialogs: vec![],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: ScreenshotResult = ipc_tool_call(
&sock,
Method::ToolScreenshot,
ScreenshotParams {
session_id,
tab_id: Some(7),
ref_: None,
},
)
.await
.expect("screenshot ok");
assert_eq!(result.image_base64, "iVBORw0KGgo=");
assert_eq!(result.width, 800);
assert_eq!(result.height, 600);
assert_eq!(result.tab_id, 7);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn screenshot_forwards_ref_to_extension() {
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::ToolScreenshot);
let params_value = req.params.clone().unwrap();
let Value::Object(map) = &params_value else {
panic!("screenshot params should be an object");
};
assert_eq!(map.get("ref").and_then(Value::as_str), Some("@e5"));
assert!(map.get("ref_").is_none());
let params: ScreenshotParams = serde_json::from_value(params_value).unwrap();
assert_eq!(params.ref_.as_deref(), Some("@e5"));
ResponseBody::Ok(
serde_json::to_value(ScreenshotResult {
image_base64: "iVBORw0KGgo=".into(),
width: 100,
height: 60,
format: "png".into(),
tab_id: 7,
dialogs: vec![],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: ScreenshotResult = ipc_tool_call(
&sock,
Method::ToolScreenshot,
ScreenshotParams {
session_id,
tab_id: Some(7),
ref_: Some("@e5".into()),
},
)
.await
.expect("screenshot with ref ok");
assert_eq!(result.width, 100);
assert_eq!(result.height, 60);
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;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = do_handshake(&mut ws).await;
run_extension(ws, |req| {
assert_eq!(req.method, Method::ToolSnapshot);
let _: SnapshotParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
ResponseBody::Ok(
serde_json::to_value(SnapshotResult {
text: "@e1 link \"home\"\n@e2 button \"submit\"\n".into(),
ref_count: 2,
tab_id: 13,
truncated: false,
dialogs: vec![],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: SnapshotResult = ipc_tool_call(
&sock,
Method::ToolSnapshot,
SnapshotParams {
session_id,
tab_id: None,
max_depth: None,
max_tokens: None,
},
)
.await
.expect("snapshot ok");
assert_eq!(result.ref_count, 2);
assert!(result.text.contains("@e1"));
assert_eq!(result.tab_id, 13);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_html_round_trips_with_ref_alias() {
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 the wire payload uses `ref` (not `ref_`).
let params = req.params.clone().unwrap();
if let Value::Object(map) = &params {
assert!(map.contains_key("ref"));
assert!(!map.contains_key("ref_"));
}
let _: GetHtmlParams = serde_json::from_value(params).unwrap();
ResponseBody::Ok(
serde_json::to_value(GetHtmlResult {
html: "<html><body>hi</body></html>".into(),
truncated: false,
byte_size: 28,
tab_id: 4,
dialogs: vec![],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: GetHtmlResult = ipc_tool_call(
&sock,
Method::ToolGetHtml,
GetHtmlParams {
session_id,
tab_id: Some(4),
ref_: Some("e3".into()),
max_bytes: None,
},
)
.await
.expect("get_html ok");
assert_eq!(result.byte_size, 28);
assert!(!result.truncated);
assert_eq!(result.tab_id, 4);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_dispatch_rejects_missing_session_id() {
let (handle, sock) = spawn_daemon().await;
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let outcome: Result<TabListResult, RpcError> = ipc
.call::<Value, TabListResult>(
"x",
Method::ToolTabList,
Some(json!({})),
Duration::from_secs(2),
)
.await
.unwrap();
let err = outcome.expect_err("expected invalid_params");
assert_eq!(err.code, ErrorCode::InvalidParams);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_dispatch_rejects_unknown_session() {
let (handle, sock) = spawn_daemon().await;
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let outcome: Result<TabListResult, RpcError> = ipc
.call::<Value, TabListResult>(
"x",
Method::ToolTabList,
Some(json!({"session_id": "zzzz"})),
Duration::from_secs(2),
)
.await
.unwrap();
let err = outcome.expect_err("expected not_found");
assert_eq!(err.code, ErrorCode::NotFound);
handle.shutdown().await;
}
+674
View File
@@ -0,0 +1,674 @@
//! M7 integration: drive the 7 new tool RPCs (navigate /
//! navigate_back / navigate_forward / reload / click / fill / press)
//! through the IPC + per-session queue + fake extension and assert
//! the wire shapes line up. The extension stub mirrors each method's
//! `*Result` so we exercise the daemon's serialise → forward →
//! deserialise → return path end-to-end.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use bsk::daemon::{self, DaemonConfig};
use bsk::ipc_client::IpcClient;
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
use bsk_protocol::tools::{
ClickParams, ClickResult, FillParams, FillResult, KeyModifier, MouseButton, NavigateBackParams,
NavigateBackResult, NavigateForwardParams, NavigateForwardResult, NavigateParams,
NavigateResult, PressParams, PressResult, ReloadParams, ReloadResult, SelectParams,
SelectResult, SessionStartParams, SessionStartResult, WaitUntil,
};
use bsk_protocol::{
BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError,
};
use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
fn tempfile_path(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let mut rng = rand::thread_rng();
let suffix: String = (0..8)
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
.collect();
p.push(format!("{prefix}-{}-{suffix}.sock", std::process::id()));
p
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let sock = tempfile_path("bsk-test-tools-m7");
let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone()))
.await
.unwrap();
(handle, sock)
}
type Ws =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
async fn connect_ext(addr: std::net::SocketAddr) -> Ws {
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let url = format!("ws://{addr}/");
let req = Request::builder()
.method("GET")
.uri(&url)
.header("Host", addr.to_string())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.header("Origin", origin)
.body(())
.unwrap();
let (ws, _) = tokio_tungstenite::connect_async(req).await.unwrap();
ws
}
async fn do_handshake(ws: &mut Ws) -> HandshakeResult {
let params = HandshakeParams {
client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(),
instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo {
name: "chrome".into(),
version: "131.0".into(),
},
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()),
label: "Test".into(),
};
let req = RequestFrame {
id: "hs".into(),
method: Method::SystemHandshake,
params: Some(serde_json::to_value(params).unwrap()),
};
ws.send(Message::Text(serde_json::to_string(&req).unwrap()))
.await
.unwrap();
let resp = ws.next().await.unwrap().unwrap();
let text = match resp {
Message::Text(t) => t,
_ => panic!("expected text"),
};
let frame: ResponseFrame = serde_json::from_str(&text).unwrap();
match frame.body {
ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(),
ResponseBody::Err(e) => panic!("handshake rejected: {e:?}"),
}
}
fn run_extension<F>(ws: Ws, reply: F)
where
F: Fn(&RequestFrame) -> ResponseBody + Send + Sync + 'static,
{
let (writer, reader) = ws.split();
let writer: Arc<Mutex<SplitSink<Ws, Message>>> = Arc::new(Mutex::new(writer));
let mut reader: SplitStream<Ws> = reader;
let reply = Arc::new(reply);
let mut window_id: i64 = 100;
tokio::spawn(async move {
while let Some(Ok(msg)) = reader.next().await {
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let frame: Frame = match serde_json::from_str(&text) {
Ok(f) => f,
Err(_) => continue,
};
let Frame::Request(req) = frame else { continue };
let body = match req.method {
Method::ToolSessionStart => {
let _: SessionStartParams =
serde_json::from_value(req.params.clone().unwrap()).unwrap();
let id = window_id;
window_id += 1;
ResponseBody::Ok(
serde_json::to_value(SessionStartResult {
agent_window_id: Some(id),
})
.unwrap(),
)
}
Method::ToolSessionStop => ResponseBody::Ok(json!({})),
_ => reply(&req),
};
let resp = ResponseFrame {
id: req.id.clone(),
body,
};
let mut w = writer.lock().await;
w.send(Message::Text(serde_json::to_string(&resp).unwrap()))
.await
.unwrap();
}
});
}
async fn ipc_session_start(sock: &PathBuf) -> String {
let mut ipc = IpcClient::connect(sock).await.unwrap();
#[derive(serde::Deserialize)]
struct R {
session_id: String,
}
let r: R = ipc
.call::<(), _>("s", Method::SessionStart, None, Duration::from_secs(5))
.await
.unwrap()
.expect("session.start ok");
r.session_id
}
async fn ipc_tool_call<P: serde::Serialize, R: serde::de::DeserializeOwned>(
sock: &PathBuf,
method: Method,
params: P,
) -> Result<R, RpcError> {
let mut ipc = IpcClient::connect(sock).await.unwrap();
ipc.call::<P, R>("t", method, Some(params), Duration::from_secs(15))
.await
.unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn navigate_round_trips_wait_until_and_reached() {
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::ToolNavigate);
let p: NavigateParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
assert_eq!(p.url, "https://example.com/");
assert_eq!(p.wait_until, Some(WaitUntil::DomContentLoaded));
ResponseBody::Ok(
serde_json::to_value(NavigateResult {
tab_id: 17,
url: p.url.clone(),
final_url: Some("https://example.com/landing".into()),
reached: "domcontentloaded".into(),
error_text: None,
dialogs: vec![],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: NavigateResult = ipc_tool_call(
&sock,
Method::ToolNavigate,
NavigateParams {
session_id,
url: "https://example.com/".into(),
tab_id: None,
wait_until: Some(WaitUntil::DomContentLoaded),
timeout_ms: Some(5_000),
},
)
.await
.expect("navigate ok");
assert_eq!(result.tab_id, 17);
assert_eq!(result.reached, "domcontentloaded");
assert_eq!(
result.final_url.as_deref(),
Some("https://example.com/landing")
);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn navigate_back_round_trips_previous_url() {
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| match &req.method {
Method::ToolNavigateBack => {
let _: NavigateBackParams =
serde_json::from_value(req.params.clone().unwrap()).unwrap();
ResponseBody::Ok(
serde_json::to_value(NavigateBackResult {
tab_id: 4,
previous_url: Some("https://b.example/".into()),
final_url: Some("https://a.example/".into()),
reached: "load".into(),
error_text: None,
dialogs: vec![],
})
.unwrap(),
)
}
Method::ToolNavigateForward => {
let _: NavigateForwardParams =
serde_json::from_value(req.params.clone().unwrap()).unwrap();
ResponseBody::Ok(
serde_json::to_value(NavigateForwardResult {
tab_id: 4,
previous_url: Some("https://a.example/".into()),
final_url: Some("https://b.example/".into()),
reached: "load".into(),
error_text: None,
dialogs: vec![],
})
.unwrap(),
)
}
Method::ToolReload => {
let p: ReloadParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
assert_eq!(p.hard, Some(true));
ResponseBody::Ok(
serde_json::to_value(ReloadResult {
tab_id: 4,
previous_url: Some("https://b.example/".into()),
final_url: Some("https://b.example/".into()),
reached: "load".into(),
error_text: None,
dialogs: vec![],
})
.unwrap(),
)
}
other => panic!("unexpected method {other:?}"),
});
let session_id = ipc_session_start(&sock).await;
let back: NavigateBackResult = ipc_tool_call(
&sock,
Method::ToolNavigateBack,
NavigateBackParams {
session_id: session_id.clone(),
tab_id: None,
wait_until: Some(WaitUntil::Load),
timeout_ms: Some(5_000),
},
)
.await
.expect("back ok");
assert_eq!(back.previous_url.as_deref(), Some("https://b.example/"));
let fwd: NavigateForwardResult = ipc_tool_call(
&sock,
Method::ToolNavigateForward,
NavigateForwardParams {
session_id: session_id.clone(),
tab_id: None,
wait_until: Some(WaitUntil::Load),
timeout_ms: Some(5_000),
},
)
.await
.expect("forward ok");
assert_eq!(fwd.final_url.as_deref(), Some("https://b.example/"));
let reload: ReloadResult = ipc_tool_call(
&sock,
Method::ToolReload,
ReloadParams {
session_id,
tab_id: None,
wait_until: Some(WaitUntil::Load),
timeout_ms: Some(5_000),
hard: Some(true),
},
)
.await
.expect("reload ok");
assert_eq!(reload.reached, "load");
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn click_round_trips_ref_and_modifiers() {
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::ToolClick);
// Verify the wire form: `ref` not `ref_`.
let params = req.params.clone().unwrap();
if let Value::Object(map) = &params {
assert!(map.contains_key("ref"));
assert!(!map.contains_key("ref_"));
}
let p: ClickParams = serde_json::from_value(params).unwrap();
assert_eq!(p.ref_.as_deref(), Some("@e3"));
assert_eq!(p.button, Some(MouseButton::Right));
assert_eq!(
p.modifiers,
Some(vec![KeyModifier::Ctrl, KeyModifier::Shift])
);
ResponseBody::Ok(
serde_json::to_value(ClickResult {
tab_id: 9,
used_ref: Some("e3".into()),
used_selector: None,
x: 12.5,
y: 34.0,
dialogs: vec![],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: ClickResult = ipc_tool_call(
&sock,
Method::ToolClick,
ClickParams {
session_id,
ref_: Some("@e3".into()),
selector: None,
tab_id: None,
button: Some(MouseButton::Right),
click_count: Some(1),
modifiers: Some(vec![KeyModifier::Ctrl, KeyModifier::Shift]),
timeout_ms: Some(5_000),
},
)
.await
.expect("click ok");
assert_eq!(result.used_ref.as_deref(), Some("e3"));
assert!((result.x - 12.5).abs() < f64::EPSILON);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fill_round_trips_clear_before_default() {
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::ToolFill);
let p: FillParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
assert_eq!(p.value, "hello world");
// clear_before omitted on the wire → None (extension applies default true).
assert_eq!(p.clear_before, None);
ResponseBody::Ok(
serde_json::to_value(FillResult {
tab_id: 12,
used_ref: None,
used_selector: Some(".search".into()),
value_length: 11,
dialogs: vec![],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: FillResult = ipc_tool_call(
&sock,
Method::ToolFill,
FillParams {
session_id,
value: "hello world".into(),
ref_: None,
selector: Some(".search".into()),
tab_id: None,
clear_before: None,
timeout_ms: Some(5_000),
},
)
.await
.expect("fill ok");
assert_eq!(result.value_length, 11);
assert_eq!(result.used_selector.as_deref(), Some(".search"));
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn press_round_trips_compound_key() {
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::ToolPress);
let p: PressParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
assert_eq!(p.key, "Ctrl+A");
ResponseBody::Ok(
serde_json::to_value(PressResult {
tab_id: 4,
key: "A".into(),
code: "KeyA".into(),
modifiers: vec![KeyModifier::Ctrl],
dialogs: vec![],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: PressResult = ipc_tool_call(
&sock,
Method::ToolPress,
PressParams {
session_id,
key: "Ctrl+A".into(),
modifiers: None,
ref_: None,
selector: None,
tab_id: None,
hold_ms: None,
timeout_ms: Some(5_000),
},
)
.await
.expect("press ok");
assert_eq!(result.code, "KeyA");
assert_eq!(result.modifiers, vec![KeyModifier::Ctrl]);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn select_round_trips_values() {
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::ToolSelect);
let p: SelectParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
assert_eq!(p.values, vec!["us".to_string(), "ca".to_string()]);
ResponseBody::Ok(
serde_json::to_value(SelectResult {
tab_id: 12,
used_ref: Some("e3".into()),
used_selector: None,
multiple: true,
selected_values: vec!["us".into(), "ca".into()],
selected_labels: vec!["United States".into(), "Canada".into()],
dialogs: vec![],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: SelectResult = ipc_tool_call(
&sock,
Method::ToolSelect,
SelectParams {
session_id,
values: vec!["us".into(), "ca".into()],
ref_: Some("@e3".into()),
selector: None,
tab_id: None,
timeout_ms: Some(5_000),
},
)
.await
.expect("select ok");
assert!(result.multiple);
assert_eq!(result.selected_values, vec!["us", "ca"]);
assert_eq!(result.selected_labels, vec!["United States", "Canada"]);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn m7_tools_propagate_extension_errors() {
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| match &req.method {
Method::ToolNavigate
| Method::ToolNavigateBack
| Method::ToolNavigateForward
| Method::ToolReload
| Method::ToolClick
| Method::ToolFill
| Method::ToolPress
| Method::ToolSelect => ResponseBody::Err(RpcError {
code: ErrorCode::CdpFailed,
message: format!("forced error for {:?}", req.method),
data: None,
}),
other => panic!("unexpected method {other:?}"),
});
let session_id = ipc_session_start(&sock).await;
let nav = ipc_tool_call::<_, Value>(
&sock,
Method::ToolNavigate,
NavigateParams {
session_id: session_id.clone(),
url: "https://example.com/".into(),
tab_id: None,
wait_until: None,
timeout_ms: Some(5_000),
},
)
.await
.unwrap_err();
assert_eq!(nav.code, ErrorCode::CdpFailed);
let back = ipc_tool_call::<_, Value>(
&sock,
Method::ToolNavigateBack,
NavigateBackParams {
session_id: session_id.clone(),
tab_id: None,
wait_until: None,
timeout_ms: Some(5_000),
},
)
.await
.unwrap_err();
assert_eq!(back.code, ErrorCode::CdpFailed);
let forward = ipc_tool_call::<_, Value>(
&sock,
Method::ToolNavigateForward,
NavigateForwardParams {
session_id: session_id.clone(),
tab_id: None,
wait_until: None,
timeout_ms: Some(5_000),
},
)
.await
.unwrap_err();
assert_eq!(forward.code, ErrorCode::CdpFailed);
let reload = ipc_tool_call::<_, Value>(
&sock,
Method::ToolReload,
ReloadParams {
session_id: session_id.clone(),
tab_id: None,
wait_until: None,
timeout_ms: Some(5_000),
hard: None,
},
)
.await
.unwrap_err();
assert_eq!(reload.code, ErrorCode::CdpFailed);
let click = ipc_tool_call::<_, Value>(
&sock,
Method::ToolClick,
ClickParams {
session_id: session_id.clone(),
ref_: Some("@e1".into()),
selector: None,
tab_id: None,
button: None,
click_count: None,
modifiers: None,
timeout_ms: Some(5_000),
},
)
.await
.unwrap_err();
assert_eq!(click.code, ErrorCode::CdpFailed);
let fill = ipc_tool_call::<_, Value>(
&sock,
Method::ToolFill,
FillParams {
session_id: session_id.clone(),
value: "hello".into(),
ref_: None,
selector: Some("input".into()),
tab_id: None,
clear_before: None,
timeout_ms: Some(5_000),
},
)
.await
.unwrap_err();
assert_eq!(fill.code, ErrorCode::CdpFailed);
let press = ipc_tool_call::<_, Value>(
&sock,
Method::ToolPress,
PressParams {
session_id: session_id.clone(),
key: "Enter".into(),
modifiers: None,
ref_: None,
selector: None,
tab_id: None,
hold_ms: None,
timeout_ms: Some(5_000),
},
)
.await
.unwrap_err();
assert_eq!(press.code, ErrorCode::CdpFailed);
let select = ipc_tool_call::<_, Value>(
&sock,
Method::ToolSelect,
SelectParams {
session_id: session_id.clone(),
values: vec!["a".into()],
ref_: Some("@e1".into()),
selector: None,
tab_id: None,
timeout_ms: Some(5_000),
},
)
.await
.unwrap_err();
assert_eq!(select.code, ErrorCode::CdpFailed);
handle.shutdown().await;
}
+529
View File
@@ -0,0 +1,529 @@
//! M8 integration: drive the 5 new `tool.tab_*` RPCs (create / close /
//! select / borrow / return) through the IPC + per-session queue +
//! fake extension and assert the wire shapes line up with bsk-protocol.
//! Mirrors `tools_m7_ipc.rs` for the navigate / click / fill / press
//! tools so the daemon's serialise → forward → deserialise → return
//! path stays covered end-to-end.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use bsk::daemon::{self, DaemonConfig};
use bsk::ipc_client::IpcClient;
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
use bsk_protocol::tools::{
ReturnFailure, SessionStartParams, SessionStartResult, SessionStopResult, TabBorrowParams,
TabBorrowResult, TabCloseParams, TabCloseResult, TabCreateParams, TabCreateResult,
TabReturnParams, TabReturnResult, TabSelectParams, TabSelectResult,
};
use bsk_protocol::{
BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError,
};
use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use serde_json::Value;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
fn tempfile_path(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let mut rng = rand::thread_rng();
let suffix: String = (0..8)
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
.collect();
p.push(format!("{prefix}-{}-{suffix}.sock", std::process::id()));
p
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let sock = tempfile_path("bsk-test-tools-m8");
let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone()))
.await
.unwrap();
(handle, sock)
}
type Ws =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
async fn connect_ext(addr: std::net::SocketAddr) -> Ws {
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let url = format!("ws://{addr}/");
let req = Request::builder()
.method("GET")
.uri(&url)
.header("Host", addr.to_string())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.header("Origin", origin)
.body(())
.unwrap();
let (ws, _) = tokio_tungstenite::connect_async(req).await.unwrap();
ws
}
async fn do_handshake(ws: &mut Ws) -> HandshakeResult {
let params = HandshakeParams {
client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(),
instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo {
name: "chrome".into(),
version: "131.0".into(),
},
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()),
label: "Test".into(),
};
let req = RequestFrame {
id: "hs".into(),
method: Method::SystemHandshake,
params: Some(serde_json::to_value(params).unwrap()),
};
ws.send(Message::Text(serde_json::to_string(&req).unwrap()))
.await
.unwrap();
let resp = ws.next().await.unwrap().unwrap();
let text = match resp {
Message::Text(t) => t,
_ => panic!("expected text"),
};
let frame: ResponseFrame = serde_json::from_str(&text).unwrap();
match frame.body {
ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(),
ResponseBody::Err(e) => panic!("handshake rejected: {e:?}"),
}
}
fn run_extension<F>(ws: Ws, reply: F)
where
F: Fn(&RequestFrame) -> ResponseBody + Send + Sync + 'static,
{
let (writer, reader) = ws.split();
let writer: Arc<Mutex<SplitSink<Ws, Message>>> = Arc::new(Mutex::new(writer));
let mut reader: SplitStream<Ws> = reader;
let reply = Arc::new(reply);
let mut window_id: i64 = 100;
tokio::spawn(async move {
while let Some(Ok(msg)) = reader.next().await {
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let frame: Frame = match serde_json::from_str(&text) {
Ok(f) => f,
Err(_) => continue,
};
let Frame::Request(req) = frame else { continue };
let body = match req.method {
Method::ToolSessionStart => {
let _: SessionStartParams =
serde_json::from_value(req.params.clone().unwrap()).unwrap();
let id = window_id;
window_id += 1;
ResponseBody::Ok(
serde_json::to_value(SessionStartResult {
agent_window_id: Some(id),
})
.unwrap(),
)
}
Method::ToolSessionStop => reply(&req),
_ => reply(&req),
};
let resp = ResponseFrame {
id: req.id.clone(),
body,
};
let mut w = writer.lock().await;
w.send(Message::Text(serde_json::to_string(&resp).unwrap()))
.await
.unwrap();
}
});
}
async fn ipc_session_start(sock: &PathBuf) -> String {
let mut ipc = IpcClient::connect(sock).await.unwrap();
#[derive(serde::Deserialize)]
struct R {
session_id: String,
}
let r: R = ipc
.call::<(), _>("s", Method::SessionStart, None, Duration::from_secs(5))
.await
.unwrap()
.expect("session.start ok");
r.session_id
}
async fn ipc_tool_call<P: serde::Serialize, R: serde::de::DeserializeOwned>(
sock: &PathBuf,
method: Method,
params: P,
) -> Result<R, RpcError> {
let mut ipc = IpcClient::connect(sock).await.unwrap();
ipc.call::<P, R>("t", method, Some(params), Duration::from_secs(15))
.await
.unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tab_create_round_trips_url_and_index() {
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::ToolTabCreate);
let p: TabCreateParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
assert_eq!(p.url.as_deref(), Some("https://example.com/"));
assert_eq!(p.active, Some(false));
assert_eq!(p.index, Some(2));
ResponseBody::Ok(
serde_json::to_value(TabCreateResult {
tab_id: 17,
window_id: 100,
url: "https://example.com/".into(),
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: TabCreateResult = ipc_tool_call(
&sock,
Method::ToolTabCreate,
TabCreateParams {
session_id,
url: Some("https://example.com/".into()),
active: Some(false),
index: Some(2),
},
)
.await
.expect("tab_create ok");
assert_eq!(result.tab_id, 17);
assert_eq!(result.window_id, 100);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tab_close_round_trips_tab_id() {
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::ToolTabClose);
let p: TabCloseParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
ResponseBody::Ok(serde_json::to_value(TabCloseResult { tab_id: p.tab_id }).unwrap())
});
let session_id = ipc_session_start(&sock).await;
let result: TabCloseResult = ipc_tool_call(
&sock,
Method::ToolTabClose,
TabCloseParams {
session_id,
tab_id: 33,
},
)
.await
.expect("tab_close ok");
assert_eq!(result.tab_id, 33);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tab_close_propagates_invalid_params_for_borrowed_tab() {
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| match &req.method {
Method::ToolTabClose => ResponseBody::Err(RpcError {
code: ErrorCode::InvalidParams,
message: "tab_close: tab 33 is borrowed; call tab_return first".into(),
data: None,
}),
other => panic!("unexpected method {other:?}"),
});
let session_id = ipc_session_start(&sock).await;
let err = ipc_tool_call::<_, Value>(
&sock,
Method::ToolTabClose,
TabCloseParams {
session_id,
tab_id: 33,
},
)
.await
.expect_err("expected invalid_params error");
assert_eq!(err.code, ErrorCode::InvalidParams);
assert!(err.message.contains("borrowed"));
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tab_select_round_trips_window_id() {
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::ToolTabSelect);
let p: TabSelectParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
ResponseBody::Ok(
serde_json::to_value(TabSelectResult {
tab_id: p.tab_id,
window_id: 100,
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: TabSelectResult = ipc_tool_call(
&sock,
Method::ToolTabSelect,
TabSelectParams {
session_id,
tab_id: 8,
},
)
.await
.expect("tab_select ok");
assert_eq!(result.window_id, 100);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tab_select_propagates_permission_denied_across_sessions() {
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| match &req.method {
Method::ToolTabSelect => ResponseBody::Err(RpcError {
code: ErrorCode::PermissionDenied,
message: "tab_select: tab 9 is not in Agent Window 200".into(),
data: None,
}),
other => panic!("unexpected method {other:?}"),
});
let session_id = ipc_session_start(&sock).await;
let err = ipc_tool_call::<_, Value>(
&sock,
Method::ToolTabSelect,
TabSelectParams {
session_id,
tab_id: 9,
},
)
.await
.expect_err("expected permission_denied");
assert_eq!(err.code, ErrorCode::PermissionDenied);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tab_borrow_round_trips_original_position() {
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::ToolTabBorrow);
let p: TabBorrowParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
assert_eq!(p.confirm, Some(false));
ResponseBody::Ok(
serde_json::to_value(TabBorrowResult {
tab_id: p.tab_id,
original_window_id: 200,
original_index: 4,
agent_window_id: 100,
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: TabBorrowResult = ipc_tool_call(
&sock,
Method::ToolTabBorrow,
TabBorrowParams {
session_id,
tab_id: 9,
confirm: Some(false),
},
)
.await
.expect("tab_borrow ok");
assert_eq!(result.original_window_id, 200);
assert_eq!(result.original_index, 4);
assert_eq!(result.agent_window_id, 100);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tab_return_round_trips_fallback_flag() {
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::ToolTabReturn);
let p: TabReturnParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
ResponseBody::Ok(
serde_json::to_value(TabReturnResult {
tab_id: p.tab_id,
returned_to_window_id: 500,
returned_to_index: -1,
fallback: true,
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: TabReturnResult = ipc_tool_call(
&sock,
Method::ToolTabReturn,
TabReturnParams {
session_id,
tab_id: 9,
},
)
.await
.expect("tab_return ok");
assert_eq!(result.returned_to_window_id, 500);
assert!(result.fallback);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tab_return_propagates_not_found_when_not_borrowed() {
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| match &req.method {
Method::ToolTabReturn => ResponseBody::Err(RpcError {
code: ErrorCode::NotFound,
message: "tab_return: tab 9 is not borrowed by this session".into(),
data: None,
}),
other => panic!("unexpected method {other:?}"),
});
let session_id = ipc_session_start(&sock).await;
let err = ipc_tool_call::<_, Value>(
&sock,
Method::ToolTabReturn,
TabReturnParams {
session_id,
tab_id: 9,
},
)
.await
.expect_err("expected not_found");
assert_eq!(err.code, ErrorCode::NotFound);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn session_stop_return_failures_keep_session_registered() {
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| match &req.method {
Method::ToolSessionStop => ResponseBody::Ok(
serde_json::to_value(SessionStopResult {
returned_tab_ids: vec![7, 8],
return_failures: vec![ReturnFailure {
tab_id: 9,
code: ErrorCode::CdpFailed,
message: "move failed".into(),
}],
})
.unwrap(),
),
other => panic!("unexpected method {other:?}"),
});
let session_id = ipc_session_start(&sock).await;
#[derive(serde::Serialize)]
struct StopParams {
session_id: Option<String>,
all: bool,
}
#[derive(serde::Deserialize)]
struct StopReply {
stopped: Vec<String>,
failed: Vec<StopFailure>,
returned_tab_ids: Vec<i64>,
return_failures: Vec<ReturnFailure>,
}
#[derive(serde::Deserialize)]
struct StopFailure {
session_id: String,
code: ErrorCode,
message: String,
}
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let result: StopReply = ipc
.call(
"stop",
Method::SessionStop,
Some(StopParams {
session_id: Some(session_id.clone()),
all: false,
}),
Duration::from_secs(15),
)
.await
.unwrap()
.expect("session.stop ok");
assert!(result.stopped.is_empty());
assert_eq!(result.failed.len(), 1);
assert_eq!(result.failed[0].session_id, session_id);
assert_eq!(result.failed[0].code, ErrorCode::CdpFailed);
assert!(
result.failed[0]
.message
.contains("failed to return borrowed tabs")
);
assert_eq!(result.returned_tab_ids, vec![7, 8]);
assert_eq!(result.return_failures[0].tab_id, 9);
assert_eq!(result.return_failures[0].code, ErrorCode::CdpFailed);
assert!(
handle
.state()
.sessions
.get(&bsk::daemon::sessions::SessionId(session_id))
.is_some(),
"session must stay registered so failed tabs can be retried"
);
handle.shutdown().await;
}
+629
View File
@@ -0,0 +1,629 @@
//! M9 integration: drive `tool.evaluate`, `tool.wait_for_navigation`,
//! and the daemon-local `tool.wait_ms` through the IPC + per-session
//! queue + fake extension. `wait_ms` is special — it never reaches
//! the extension, so the test does not run a fake-extension reply
//! callback for that method (it would be a sign of a regression if
//! the daemon dispatched it).
mod support;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use bsk::daemon::{self, DaemonConfig};
use bsk::ipc_client::IpcClient;
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
use bsk_protocol::tools::{
EvaluateError, EvaluateParams, EvaluateResult, JavaScriptDialogHandledAction,
JavaScriptDialogInfo, JavaScriptDialogType, SessionStartParams, SessionStartResult,
WaitForNavigationParams, WaitForNavigationReached, WaitForNavigationResult, WaitMsParams,
WaitMsResult, WaitUntil,
};
use bsk_protocol::{
BrowserPeerInfo, CancelParams, CancelResult, ErrorCode, Frame, Method, RequestFrame,
ResponseBody, ResponseFrame, RpcError,
};
use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use serde_json::{Value, json};
#[cfg(unix)]
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
#[cfg(unix)]
use tokio::net::UnixStream;
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
use support::wait_for_abort_registered;
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
fn tempfile_path(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let mut rng = rand::thread_rng();
let suffix: String = (0..8)
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
.collect();
p.push(format!("{prefix}-{}-{suffix}.sock", std::process::id()));
p
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let sock = tempfile_path("bsk-test-tools-m9");
let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone()))
.await
.unwrap();
(handle, sock)
}
type Ws =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
async fn connect_ext(addr: std::net::SocketAddr) -> Ws {
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let url = format!("ws://{addr}/");
let req = Request::builder()
.method("GET")
.uri(&url)
.header("Host", addr.to_string())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.header("Origin", origin)
.body(())
.unwrap();
let (ws, _) = tokio_tungstenite::connect_async(req).await.unwrap();
ws
}
async fn do_handshake(ws: &mut Ws) -> HandshakeResult {
let params = HandshakeParams {
client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(),
instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo {
name: "chrome".into(),
version: "131.0".into(),
},
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()),
label: "Test".into(),
};
let req = RequestFrame {
id: "hs".into(),
method: Method::SystemHandshake,
params: Some(serde_json::to_value(params).unwrap()),
};
ws.send(Message::Text(serde_json::to_string(&req).unwrap()))
.await
.unwrap();
let resp = ws.next().await.unwrap().unwrap();
let text = match resp {
Message::Text(t) => t,
_ => panic!("expected text"),
};
let frame: ResponseFrame = serde_json::from_str(&text).unwrap();
match frame.body {
ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(),
ResponseBody::Err(e) => panic!("handshake rejected: {e:?}"),
}
}
fn run_extension<F>(ws: Ws, reply: F)
where
F: Fn(&RequestFrame) -> ResponseBody + Send + Sync + 'static,
{
let (writer, reader) = ws.split();
let writer: Arc<Mutex<SplitSink<Ws, Message>>> = Arc::new(Mutex::new(writer));
let mut reader: SplitStream<Ws> = reader;
let reply = Arc::new(reply);
let mut window_id: i64 = 100;
tokio::spawn(async move {
while let Some(Ok(msg)) = reader.next().await {
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue,
};
let frame: Frame = match serde_json::from_str(&text) {
Ok(f) => f,
Err(_) => continue,
};
let Frame::Request(req) = frame else { continue };
let body = match req.method {
Method::ToolSessionStart => {
let _: SessionStartParams =
serde_json::from_value(req.params.clone().unwrap()).unwrap();
let id = window_id;
window_id += 1;
ResponseBody::Ok(
serde_json::to_value(SessionStartResult {
agent_window_id: Some(id),
})
.unwrap(),
)
}
Method::ToolSessionStop => ResponseBody::Ok(json!({})),
_ => reply(&req),
};
let resp = ResponseFrame {
id: req.id.clone(),
body,
};
let mut w = writer.lock().await;
w.send(Message::Text(serde_json::to_string(&resp).unwrap()))
.await
.unwrap();
}
});
}
async fn ipc_session_start(sock: &PathBuf) -> String {
let mut ipc = IpcClient::connect(sock).await.unwrap();
#[derive(serde::Deserialize)]
struct R {
session_id: String,
}
let r: R = ipc
.call::<(), _>("s", Method::SessionStart, None, Duration::from_secs(5))
.await
.unwrap()
.expect("session.start ok");
r.session_id
}
async fn ipc_tool_call<P: serde::Serialize, R: serde::de::DeserializeOwned>(
sock: &PathBuf,
method: Method,
params: P,
) -> Result<R, RpcError> {
let mut ipc = IpcClient::connect(sock).await.unwrap();
ipc.call::<P, R>("t", method, Some(params), Duration::from_secs(15))
.await
.unwrap()
}
// ---------------------------------------------------------------------------
// tool.evaluate
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn evaluate_round_trips_ok_with_value() {
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::ToolEvaluate);
let p: EvaluateParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
assert_eq!(p.expression, "1+1");
assert_eq!(p.await_promise, Some(true));
assert_eq!(p.return_by_value, Some(true));
ResponseBody::Ok(
serde_json::to_value(EvaluateResult {
ok: true,
tab_id: 4,
value: Some(json!(2)),
error: None,
dialogs: vec![],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: EvaluateResult = ipc_tool_call(
&sock,
Method::ToolEvaluate,
EvaluateParams {
session_id,
expression: "1+1".into(),
tab_id: None,
await_promise: Some(true),
return_by_value: Some(true),
timeout_ms: None,
},
)
.await
.expect("evaluate ok");
assert!(result.ok);
assert_eq!(result.value, Some(json!(2)));
assert!(result.error.is_none());
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn evaluate_round_trips_dialogs_payload() {
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| {
ResponseBody::Ok(
serde_json::to_value(EvaluateResult {
ok: true,
tab_id: 4,
value: Some(json!("done")),
error: None,
dialogs: vec![JavaScriptDialogInfo {
tab_id: 4,
dialog_type: JavaScriptDialogType::Alert,
message: "hello".into(),
url: Some("https://example.com/".into()),
default_prompt: None,
has_browser_handler: Some(false),
handled: JavaScriptDialogHandledAction::Accepted,
sequence: 1,
}],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: EvaluateResult = ipc_tool_call(
&sock,
Method::ToolEvaluate,
EvaluateParams {
session_id,
expression: "alert('hello')".into(),
tab_id: None,
await_promise: None,
return_by_value: None,
timeout_ms: None,
},
)
.await
.expect("evaluate ok");
assert_eq!(result.dialogs.len(), 1);
assert_eq!(result.dialogs[0].message, "hello");
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn evaluate_round_trips_exception_as_in_band_error() {
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| {
ResponseBody::Ok(
serde_json::to_value(EvaluateResult {
ok: false,
tab_id: 4,
value: None,
error: Some(EvaluateError {
text: "Uncaught Error: boom".into(),
line: Some(0),
column: Some(6),
}),
dialogs: vec![],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: EvaluateResult = ipc_tool_call(
&sock,
Method::ToolEvaluate,
EvaluateParams {
session_id,
expression: "throw new Error('boom')".into(),
tab_id: None,
await_promise: None,
return_by_value: None,
timeout_ms: None,
},
)
.await
.expect("evaluate call must itself succeed (the throw is in-band)");
assert!(!result.ok);
let err = result.error.expect("expected in-band error");
assert!(err.text.contains("boom"));
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn evaluate_propagates_permission_denied() {
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| {
ResponseBody::Err(RpcError {
code: ErrorCode::PermissionDenied,
message: "evaluate can only act on tabs inside the Agent Window".into(),
data: None,
})
});
let session_id = ipc_session_start(&sock).await;
let err = ipc_tool_call::<_, Value>(
&sock,
Method::ToolEvaluate,
EvaluateParams {
session_id,
expression: "1+1".into(),
tab_id: Some(11),
await_promise: None,
return_by_value: None,
timeout_ms: None,
},
)
.await
.expect_err("expected permission_denied");
assert_eq!(err.code, ErrorCode::PermissionDenied);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn evaluate_returns_not_found_for_unknown_session() {
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = do_handshake(&mut ws).await;
run_extension(ws, |_| {
panic!("unknown session must short-circuit before reaching the extension")
});
let err = ipc_tool_call::<_, Value>(
&sock,
Method::ToolEvaluate,
EvaluateParams {
session_id: "zzzz".into(),
expression: "1+1".into(),
tab_id: None,
await_promise: None,
return_by_value: None,
timeout_ms: None,
},
)
.await
.expect_err("expected not_found");
assert_eq!(err.code, ErrorCode::NotFound);
handle.shutdown().await;
}
// ---------------------------------------------------------------------------
// tool.wait_for_navigation
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn wait_for_navigation_round_trips_reached() {
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::ToolWaitForNavigation);
let p: WaitForNavigationParams =
serde_json::from_value(req.params.clone().unwrap()).unwrap();
assert_eq!(p.wait_until, Some(WaitUntil::Load));
ResponseBody::Ok(
serde_json::to_value(WaitForNavigationResult {
tab_id: 4,
reached: WaitForNavigationReached::Load,
error_text: None,
dialogs: vec![],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: WaitForNavigationResult = ipc_tool_call(
&sock,
Method::ToolWaitForNavigation,
WaitForNavigationParams {
session_id,
tab_id: None,
wait_until: Some(WaitUntil::Load),
timeout_ms: Some(1_000),
},
)
.await
.expect("wait_for_navigation ok");
assert_eq!(result.tab_id, 4);
assert_eq!(result.reached, WaitForNavigationReached::Load);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn wait_for_navigation_reports_timeout_payload() {
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| {
ResponseBody::Ok(
serde_json::to_value(WaitForNavigationResult {
tab_id: 4,
reached: WaitForNavigationReached::Timeout,
error_text: Some("timed out waiting for lifecycle \"load\"".into()),
dialogs: vec![],
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: WaitForNavigationResult = ipc_tool_call(
&sock,
Method::ToolWaitForNavigation,
WaitForNavigationParams {
session_id,
tab_id: None,
wait_until: Some(WaitUntil::Load),
timeout_ms: Some(25),
},
)
.await
.expect("wait_for_navigation ok");
assert_eq!(result.reached, WaitForNavigationReached::Timeout);
assert!(result.error_text.unwrap().contains("timed out"));
handle.shutdown().await;
}
// ---------------------------------------------------------------------------
// tool.wait_ms (daemon-local, never reaches the extension)
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn wait_ms_short_duration_returns_quickly() {
let (handle, sock) = spawn_daemon().await;
// Connect a stub extension so the daemon's `BrowserRegistry` is
// populated; wait_ms itself never sends WS traffic.
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = do_handshake(&mut ws).await;
run_extension(ws, |_req| panic!("wait_ms must not reach the extension"));
let result: WaitMsResult =
ipc_tool_call(&sock, Method::ToolWaitMs, WaitMsParams { duration_ms: 100 })
.await
.expect("wait_ms ok");
assert_eq!(result.waited_ms, 100);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn wait_ms_zero_short_circuits() {
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = do_handshake(&mut ws).await;
run_extension(ws, |_| panic!("wait_ms must not reach the extension"));
let result: WaitMsResult =
ipc_tool_call(&sock, Method::ToolWaitMs, WaitMsParams { duration_ms: 0 })
.await
.expect("wait_ms ok");
assert_eq!(result.waited_ms, 0);
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn wait_ms_rejects_over_limit_duration() {
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = do_handshake(&mut ws).await;
run_extension(ws, |_| panic!("wait_ms must not reach the extension"));
let err = ipc_tool_call::<_, Value>(
&sock,
Method::ToolWaitMs,
WaitMsParams {
duration_ms: 5 * 60 * 1_000 + 1,
},
)
.await
.expect_err("expected invalid_params");
assert_eq!(err.code, ErrorCode::InvalidParams);
assert!(err.message.to_lowercase().contains("exceeds"));
handle.shutdown().await;
}
// The cancel end-to-end test needs to issue two concurrent IPC calls
// on the same daemon (wait_ms on conn1 + cancel on conn2). Doing that
// from raw `IpcClient` would require splitting `call` into write/read
// halves; on Unix we drop straight to `UnixStream` instead. The
// Windows side relies on the unit tests in `daemon::ipc::tests` for
// the same coverage (which exercise `handle_wait_ms` directly).
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn wait_ms_cancellation_returns_cancelled_via_method_cancel() {
// End-to-end abort path: open a UDS connection, fire `tool.wait_ms`
// with a deliberate id, fire `cancel { rpc_id }` from a *second*
// connection ~100ms later, observe the original wait return
// ErrorCode::Cancelled. Mirrors the design §5 cancel envelope so
// M10.2 only has to wire SIGINT onto it.
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = do_handshake(&mut ws).await;
run_extension(ws, |_| panic!("wait_ms must not reach the extension"));
// Connection #1 — issue the wait_ms.
let stream = UnixStream::connect(&sock).await.unwrap();
let (read, mut write) = stream.into_split();
let wait_frame = Frame::Request(RequestFrame {
id: "wait-cancel-1".into(),
method: Method::ToolWaitMs,
params: Some(serde_json::to_value(WaitMsParams { duration_ms: 5_000 }).unwrap()),
});
let mut line = serde_json::to_string(&wait_frame).unwrap();
line.push('\n');
write.write_all(line.as_bytes()).await.unwrap();
write.flush().await.unwrap();
let state = handle.state();
wait_for_abort_registered(&state, &"wait-cancel-1".into()).await;
// Connection #2 — issue the cancel.
let mut cancel_client = IpcClient::connect(&sock).await.unwrap();
let cancel_reply: CancelResult = cancel_client
.call::<_, CancelResult>(
"cancel-1",
Method::Cancel,
Some(CancelParams {
rpc_id: "wait-cancel-1".into(),
}),
Duration::from_secs(2),
)
.await
.unwrap()
.expect("cancel ok");
assert!(cancel_reply.cancelled);
// Read connection #1's response — must be Cancelled, must arrive
// long before the 5000ms sleep would have completed.
let mut reader = BufReader::new(read);
let mut buf = String::new();
let start = std::time::Instant::now();
tokio::time::timeout(Duration::from_secs(2), reader.read_line(&mut buf))
.await
.expect("wait_ms cancellation must propagate fast")
.unwrap();
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"wait_ms cancellation took too long: {elapsed:?}"
);
let frame: Frame = serde_json::from_str(buf.trim_end()).unwrap();
match frame {
Frame::Response(resp) => {
assert_eq!(resp.id, "wait-cancel-1");
match resp.body {
ResponseBody::Err(err) => assert_eq!(err.code, ErrorCode::Cancelled),
other => panic!("expected cancelled, got {other:?}"),
}
}
other => panic!("unexpected frame {other:?}"),
}
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_unknown_rpc_returns_false_without_error() {
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = do_handshake(&mut ws).await;
run_extension(ws, |_| panic!("cancel must not reach the extension"));
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let reply: CancelResult = ipc
.call::<_, CancelResult>(
"cancel-noop",
Method::Cancel,
Some(CancelParams {
rpc_id: "ghost".into(),
}),
Duration::from_secs(2),
)
.await
.unwrap()
.expect("cancel ok");
assert!(!reply.cancelled);
handle.shutdown().await;
}
+189
View File
@@ -0,0 +1,189 @@
//! M4.4 integration: spin up the minimal daemon, connect a fake extension
//! over WebSocket, verify the Origin allow-list and `system.handshake`
//! registration logic.
use std::time::Duration;
use bsk::daemon::{self, DaemonConfig};
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
use bsk_protocol::{BrowserPeerInfo, Method, RequestFrame, ResponseBody, ResponseFrame};
use futures_util::{SinkExt, StreamExt};
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
pub const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop"; // 32 chars in a-p
pub async fn spawn_daemon() -> daemon::DaemonHandle {
// Bind to any free TCP port.
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let config = DaemonConfig::new(port);
daemon::run(config, None).await.unwrap()
}
pub async fn connect_ext(
addr: std::net::SocketAddr,
origin: &str,
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
let url = format!("ws://{addr}/");
let req = Request::builder()
.method("GET")
.uri(&url)
.header("Host", addr.to_string())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.header("Origin", origin)
.body(())
.unwrap();
let (ws, _resp) = tokio_tungstenite::connect_async(req)
.await
.expect("ws connect");
ws
}
pub async fn send_handshake(
ws: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
instance_id: &str,
) -> HandshakeResult {
let params = HandshakeParams {
client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(),
instance_id: instance_id.into(),
browser: BrowserPeerInfo {
name: "chrome".into(),
version: "131.0".into(),
},
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()),
label: "Test Chrome".into(),
};
let req = RequestFrame {
id: "hs-1".into(),
method: Method::SystemHandshake,
params: Some(serde_json::to_value(params).unwrap()),
};
ws.send(Message::Text(serde_json::to_string(&req).unwrap()))
.await
.unwrap();
let msg = ws.next().await.unwrap().unwrap();
let text = match msg {
Message::Text(t) => t,
_ => panic!("expected text frame"),
};
let resp: ResponseFrame = serde_json::from_str(&text).unwrap();
match resp.body {
ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(),
ResponseBody::Err(e) => panic!("handshake rejected: {e:?}"),
}
}
#[tokio::test]
async fn ws_handshake_registers_browser_in_state() {
let handle = spawn_daemon().await;
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let mut ws = connect_ext(handle.ws_addr(), &origin).await;
let result = send_handshake(&mut ws, TEST_EXT_ID).await;
assert_eq!(result.server, "browser-skill-daemon");
assert_eq!(result.protocol_version, "1.0");
let state = handle.state();
let browsers = state.browsers.snapshot();
assert_eq!(browsers.len(), 1);
assert_eq!(browsers[0].id.0, TEST_EXT_ID);
assert_eq!(browsers[0].browser_name, "chrome");
assert_eq!(browsers[0].label, "Test Chrome");
let _ = ws.close(None).await;
handle.shutdown().await;
}
#[tokio::test]
async fn ws_handshake_rejects_disallowed_origin() {
let handle = spawn_daemon().await;
let bad_origin = "https://evil.example";
let url = format!("ws://{}/", handle.ws_addr());
let req = Request::builder()
.method("GET")
.uri(&url)
.header("Host", handle.ws_addr().to_string())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.header("Origin", bad_origin)
.body(())
.unwrap();
let res = tokio_tungstenite::connect_async(req).await;
assert!(res.is_err(), "expected connect to fail (got Ok)");
handle.shutdown().await;
}
#[tokio::test]
async fn ws_handshake_first_frame_timeout_closes_silent_client() {
let handle = spawn_daemon().await;
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let mut ws = connect_ext(handle.ws_addr(), &origin).await;
// Don't send anything. The daemon must close us within ~5s rather
// than parking the task forever (review M4/M5 round 3 I-R3-1).
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let mut received_close = false;
while std::time::Instant::now() < deadline {
match tokio::time::timeout(Duration::from_millis(500), ws.next()).await {
Ok(Some(Ok(Message::Close(_)))) => {
received_close = true;
break;
}
Ok(None) | Err(_) => {
continue;
}
Ok(Some(Ok(_))) => continue,
Ok(Some(Err(_))) => {
received_close = true;
break;
}
}
}
assert!(
received_close,
"daemon should close a silent client within the handshake timeout"
);
handle.shutdown().await;
}
#[tokio::test]
async fn ws_kicks_non_handshake_first_frame() {
let handle = spawn_daemon().await;
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let mut ws = connect_ext(handle.ws_addr(), &origin).await;
let bad = RequestFrame {
id: "1".into(),
method: Method::ToolTabList,
params: Some(serde_json::json!({"session_id":"foo"})),
};
ws.send(Message::Text(serde_json::to_string(&bad).unwrap()))
.await
.unwrap();
let resp = ws.next().await.unwrap().unwrap();
let text = match resp {
Message::Text(t) => t,
other => panic!("expected text frame, got {other:?}"),
};
let resp: ResponseFrame = serde_json::from_str(&text).unwrap();
match resp.body {
ResponseBody::Err(e) => {
assert_eq!(e.code, bsk_protocol::ErrorCode::ProtocolError);
}
_ => panic!("expected error body"),
}
handle.shutdown().await;
}