mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Initial public release
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) = ¶ms_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) = ¶ms {
|
||||
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;
|
||||
}
|
||||
@@ -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) = ¶ms {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user