use std::sync::mpsc::{self, Receiver, Sender};
use std::time::Duration;
use serde_json::Value;
#[derive(Debug, Clone)]
pub enum BridgeCommand {
Navigate {
target_id: String,
url: String,
},
EvaluateJs {
target_id: String,
expression: String,
return_by_value: bool,
},
TakeScreenshot {
target_id: String,
format: String,
quality: Option<u8>,
},
GetTitle {
target_id: String,
},
GetUrl {
target_id: String,
},
GetDocument {
target_id: String,
},
QuerySelector {
target_id: String,
selector: String,
},
QuerySelectorAll {
target_id: String,
selector: String,
},
GetOuterHtml {
target_id: String,
node_id: Option<i64>,
},
SetAttributeValue {
target_id: String,
node_id: i64,
name: String,
value: String,
},
DispatchMouseEvent {
target_id: String,
event_type: String,
x: f64,
y: f64,
button: Option<i64>,
click_count: Option<i64>,
},
DispatchKeyEvent {
target_id: String,
event_type: String,
key: String,
code: String,
text: Option<String>,
},
InsertText {
target_id: String,
text: String,
},
SetViewport {
target_id: String,
width: u32,
height: u32,
device_scale_factor: Option<f64>,
},
SetUserAgent {
target_id: String,
user_agent: String,
},
GetCookies {
target_id: String,
urls: Vec<String>,
},
GetAllCookies {
target_id: String,
},
DeleteCookie {
target_id: String,
name: String,
url: Option<String>,
},
SetCookie {
target_id: String,
name: String,
value: String,
url: Option<String>,
domain: Option<String>,
},
GetResponseBody {
target_id: String,
request_id: String,
},
AddScriptToEvaluateOnNewDocument {
target_id: String,
source: String,
},
Reload {
target_id: String,
ignore_cache: bool,
},
GoBack {
target_id: String,
},
GoForward {
target_id: String,
},
StopLoading {
target_id: String,
},
ClosePage {
target_id: String,
},
CreateTarget {
url: String,
},
ListTargets,
DebuggerEnable {
target_id: String,
},
DebuggerDisable {
target_id: String,
},
DebuggerSetBreakpoint {
target_id: String,
url: Option<String>,
url_regex: Option<String>,
line: u32,
column: Option<u32>,
},
DebuggerRemoveBreakpoint {
target_id: String,
breakpoint_id: String,
},
DebuggerInterrupt {
target_id: String,
},
DebuggerResume {
target_id: String,
step_type: Option<String>,
},
DebuggerListFrames {
target_id: String,
},
DebuggerGetEnvironment {
target_id: String,
frame_actor_id: String,
},
DebuggerEval {
target_id: String,
expression: String,
frame_actor_id: Option<String>,
},
DebuggerGetPossibleBreakpoints {
target_id: String,
start_script_id: String,
},
DebuggerGetScriptSource {
target_id: String,
script_id: u32,
},
DebuggerBlackbox {
target_id: String,
script_id: u32,
},
DebuggerUnblackbox {
target_id: String,
script_id: u32,
},
NetworkEnable {
target_id: String,
},
NetworkDisable {
target_id: String,
},
NetworkSetCacheDisabled {
target_id: String,
cache_disabled: bool,
},
NetworkSetExtraHTTPHeaders {
target_id: String,
headers: Value,
},
NetworkClearBrowserCache {
target_id: String,
},
NetworkClearBrowserCookies {
target_id: String,
},
StorageGetStorageItemsForOrigin {
target_id: String,
origin: String,
storage_type: String,
},
StorageClearDataForOrigin {
target_id: String,
origin: String,
storage_type: String,
},
SecurityEnable {
target_id: String,
},
SecurityDisable {
target_id: String,
},
SecuritySetOverrideCertificateErrors {
target_id: String,
override_errors: bool,
},
ProfilerStart {
target_id: String,
},
ProfilerStop {
target_id: String,
},
ProfilerSetSamplingInterval {
target_id: String,
interval: u32,
},
HeapProfilerTakeSnapshot {
target_id: String,
},
HeapProfilerStartTracking {
target_id: String,
},
HeapProfilerStopTracking {
target_id: String,
},
HeapProfilerCollectGarbage {
target_id: String,
},
MemoryGetDOMCounters {
target_id: String,
},
MemoryPurgeJS {
target_id: String,
},
PerformanceGetMetrics {
target_id: String,
},
CssGetComputedStyleForNode {
target_id: String,
node_id: i64,
},
CssGetMatchedStylesForNode {
target_id: String,
node_id: i64,
},
CssGetInlineStylesForNode {
target_id: String,
node_id: i64,
},
RuntimeGetProperties {
target_id: String,
object_id: String,
own_properties: Option<bool>,
},
RuntimeCallFunctionOn {
target_id: String,
object_id: Option<String>,
execution_context_id: Option<i64>,
function_declaration: String,
arguments: Option<Value>,
return_by_value: Option<bool>,
await_promise: Option<bool>,
object_group: Option<String>,
},
RuntimeReleaseObject {
target_id: String,
object_id: String,
},
RuntimeReleaseObjectGroup {
target_id: String,
object_group: String,
},
ListWorkerTargets {
target_id: String,
},
GetWorkerTargetInfo {
target_id: String,
worker_id: String,
},
ListServiceWorkerRegistrations {
target_id: String,
},
GetServiceWorkerRegistrationInfo {
target_id: String,
registration_id: String,
},
TerminateServiceWorker {
target_id: String,
registration_id: String,
},
StopServiceWorker {
target_id: String,
registration_id: String,
},
}
#[derive(Debug)]
pub struct BridgeResponse {
pub result: Result<Value, String>,
}
struct BridgeRequest {
command: BridgeCommand,
responder: Sender<BridgeResponse>,
}
pub struct BridgeSender {
tx: Sender<BridgeRequest>,
timeout: Duration,
}
pub struct BridgeReceiver {
rx: Receiver<BridgeRequest>,
}
pub fn bridge_channel(timeout: Duration) -> (BridgeSender, BridgeReceiver) {
let (tx, rx) = mpsc::channel();
(BridgeSender { tx, timeout }, BridgeReceiver { rx })
}
impl BridgeSender {
pub fn send(&self, command: BridgeCommand) -> BridgeResponse {
let (resp_tx, resp_rx) = mpsc::channel();
if self
.tx
.send(BridgeRequest {
command,
responder: resp_tx,
})
.is_err()
{
return BridgeResponse {
result: Err("bridge channel closed".into()),
};
}
match resp_rx.recv_timeout(self.timeout) {
Ok(resp) => resp,
Err(_) => BridgeResponse {
result: Err("bridge response timeout".into()),
},
}
}
pub fn send_fire_and_forget(&self, command: BridgeCommand) {
let (resp_tx, _) = mpsc::channel();
let _ = self.tx.send(BridgeRequest {
command,
responder: resp_tx,
});
}
pub fn is_alive(&self) -> bool {
!self
.tx
.send(BridgeRequest {
command: BridgeCommand::ListTargets,
responder: mpsc::channel().0,
})
.is_err()
}
}
impl BridgeReceiver {
pub fn recv_and_process<F>(&self, timeout: Duration, handler: F) -> bool
where
F: FnOnce(BridgeCommand) -> BridgeResponse,
{
match self.rx.recv_timeout(timeout) {
Ok(request) => {
let response = handler(request.command);
let _ = request.responder.send(response);
true
}
Err(_) => false,
}
}
pub fn try_process<F>(&self, handler: F) -> bool
where
F: FnOnce(BridgeCommand) -> BridgeResponse,
{
match self.rx.try_recv() {
Ok(request) => {
let response = handler(request.command);
let _ = request.responder.send(response);
true
}
Err(_) => false,
}
}
pub fn drain<F>(&self, handler: F) -> usize
where
F: Fn(BridgeCommand) -> BridgeResponse,
{
let mut count = 0;
while let Ok(request) = self.rx.try_recv() {
let response = handler(request.command);
let _ = request.responder.send(response);
count += 1;
}
count
}
}
impl std::clone::Clone for BridgeSender {
fn clone(&self) -> Self {
BridgeSender {
tx: self.tx.clone(),
timeout: self.timeout,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_millis(100);
fn ok_response(val: Value) -> BridgeResponse {
BridgeResponse { result: Ok(val) }
}
fn err_response(msg: &str) -> BridgeResponse {
BridgeResponse {
result: Err(msg.into()),
}
}
fn noop_handler(_: BridgeCommand) -> BridgeResponse {
ok_response(Value::Null)
}
const TID: &str = "test-target";
#[test]
fn bridge_channel_creates_sender_and_receiver() {
let (sender, _receiver) = bridge_channel(TIMEOUT);
assert!(
sender.is_alive(),
"sender should report alive when receiver exists"
);
}
#[test]
fn send_with_responding_receiver_returns_ok() {
let (sender, receiver) = bridge_channel(TIMEOUT);
sender.send_fire_and_forget(BridgeCommand::GetTitle {
target_id: TID.into(),
});
let mut captured_response: Option<BridgeResponse> = None;
let processed = receiver.try_process(|cmd| {
let resp = match cmd {
BridgeCommand::GetTitle { .. } => ok_response(Value::String("Test Page".into())),
_ => err_response("unexpected"),
};
captured_response = Some(BridgeResponse {
result: resp.result.clone(),
});
resp
});
assert!(processed);
let resp = captured_response.unwrap();
assert!(resp.result.is_ok());
assert_eq!(resp.result.unwrap(), Value::String("Test Page".into()));
}
#[test]
fn send_when_receiver_dropped_returns_channel_closed() {
let (sender, receiver) = bridge_channel(TIMEOUT);
drop(receiver);
let resp = sender.send(BridgeCommand::GetTitle {
target_id: TID.into(),
});
assert!(resp.result.is_err());
assert_eq!(resp.result.unwrap_err(), "bridge channel closed");
}
#[test]
fn send_fire_and_forget_does_not_panic() {
let (sender, receiver) = bridge_channel(TIMEOUT);
sender.send_fire_and_forget(BridgeCommand::GetTitle {
target_id: TID.into(),
});
let processed = receiver.try_process(noop_handler);
assert!(processed, "fire-and-forget command should be receivable");
}
#[test]
fn is_alive_when_channel_open_returns_true() {
let (sender, _receiver) = bridge_channel(TIMEOUT);
assert!(sender.is_alive());
}
#[test]
fn clone_preserves_connection() {
let (sender, receiver) = bridge_channel(TIMEOUT);
let cloned = sender.clone();
cloned.send_fire_and_forget(BridgeCommand::GetTitle {
target_id: TID.into(),
});
let processed = receiver.try_process(noop_handler);
assert!(
processed,
"cloned sender should deliver command to same receiver"
);
}
#[test]
fn try_process_processes_one_command() {
let (sender, receiver) = bridge_channel(TIMEOUT);
sender.send_fire_and_forget(BridgeCommand::GetTitle {
target_id: TID.into(),
});
let processed = receiver.try_process(|_| ok_response(Value::Bool(true)));
assert!(processed);
let again = receiver.try_process(noop_handler);
assert!(!again, "no second command should be pending");
}
#[test]
fn drain_processes_multiple_commands() {
let (sender, receiver) = bridge_channel(TIMEOUT);
for _ in 0..5 {
sender.send_fire_and_forget(BridgeCommand::GetTitle {
target_id: TID.into(),
});
}
let count = receiver.drain(noop_handler);
assert_eq!(count, 5);
}
#[test]
fn try_process_returns_false_when_empty() {
let (_sender, receiver) = bridge_channel(TIMEOUT);
let processed = receiver.try_process(noop_handler);
assert!(!processed);
}
#[test]
fn recv_and_process_receives_command() {
let (sender, receiver) = bridge_channel(TIMEOUT);
let sender_thread = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(10));
sender.send_fire_and_forget(BridgeCommand::GetTitle {
target_id: TID.into(),
});
});
let processed = receiver.recv_and_process(TIMEOUT, |_| ok_response(Value::Bool(true)));
assert!(processed, "recv_and_process should receive the command");
sender_thread.join().unwrap();
}
#[test]
fn recv_and_process_returns_false_on_timeout() {
let (_sender, receiver) = bridge_channel(Duration::from_millis(50));
let processed = receiver.recv_and_process(Duration::from_millis(50), noop_handler);
assert!(
!processed,
"recv_and_process should return false on timeout"
);
}
#[test]
fn bridge_response_ok_with_value() {
let resp = ok_response(Value::Number(42.into()));
assert!(resp.result.is_ok());
assert_eq!(resp.result.unwrap(), Value::Number(42.into()));
}
#[test]
fn bridge_response_err_with_string() {
let resp = err_response("something failed");
assert!(resp.result.is_err());
assert_eq!(resp.result.unwrap_err(), "something failed");
}
#[test]
fn navigate_debug_format_contains_navigate() {
let cmd = BridgeCommand::Navigate {
target_id: TID.into(),
url: "https://example.com".into(),
};
let debug_str = format!("{:?}", cmd);
assert!(
debug_str.contains("Navigate"),
"debug output should contain 'Navigate': {}",
debug_str
);
}
#[test]
fn evaluate_js_debug_format() {
let cmd = BridgeCommand::EvaluateJs {
target_id: TID.into(),
expression: "1+1".into(),
return_by_value: true,
};
let debug_str = format!("{:?}", cmd);
assert!(
debug_str.contains("EvaluateJs"),
"debug output should contain 'EvaluateJs': {}",
debug_str
);
}
#[test]
fn take_screenshot_debug_format() {
let cmd = BridgeCommand::TakeScreenshot {
target_id: TID.into(),
format: "png".into(),
quality: Some(80),
};
let debug_str = format!("{:?}", cmd);
assert!(
debug_str.contains("TakeScreenshot"),
"debug output should contain 'TakeScreenshot': {}",
debug_str
);
}
#[test]
fn dispatch_mouse_event_construction() {
let cmd = BridgeCommand::DispatchMouseEvent {
target_id: TID.into(),
event_type: "mouseMoved".into(),
x: 100.0,
y: 200.0,
button: Some(0),
click_count: Some(2),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("DispatchMouseEvent"));
assert!(debug_str.contains("mouseMoved"));
}
#[test]
fn dispatch_key_event_construction() {
let cmd = BridgeCommand::DispatchKeyEvent {
target_id: TID.into(),
event_type: "keyDown".into(),
key: "Enter".into(),
code: "Enter".into(),
text: Some("\r".into()),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("DispatchKeyEvent"));
}
#[test]
fn set_viewport_construction() {
let cmd = BridgeCommand::SetViewport {
target_id: TID.into(),
width: 1920,
height: 1080,
device_scale_factor: Some(2.0),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("SetViewport"));
}
#[test]
fn set_cookie_construction() {
let cmd = BridgeCommand::SetCookie {
target_id: TID.into(),
name: "session".into(),
value: "abc123".into(),
url: Some("https://example.com".into()),
domain: Some(".example.com".into()),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("SetCookie"));
}
#[test]
fn get_response_body_construction() {
let cmd = BridgeCommand::GetResponseBody {
target_id: TID.into(),
request_id: "req-001".into(),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("GetResponseBody"));
}
#[test]
fn add_script_to_evaluate_on_new_document_construction() {
let cmd = BridgeCommand::AddScriptToEvaluateOnNewDocument {
target_id: TID.into(),
source: "console.log('hi')".into(),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("AddScriptToEvaluateOnNewDocument"));
}
#[test]
fn reload_construction() {
let cmd = BridgeCommand::Reload {
target_id: TID.into(),
ignore_cache: true,
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("Reload"));
}
#[test]
fn go_back_construction() {
let cmd = BridgeCommand::GoBack {
target_id: TID.into(),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("GoBack"));
}
#[test]
fn go_forward_construction() {
let cmd = BridgeCommand::GoForward {
target_id: TID.into(),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("GoForward"));
}
#[test]
fn stop_loading_construction() {
let cmd = BridgeCommand::StopLoading {
target_id: TID.into(),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("StopLoading"));
}
#[test]
fn close_page_construction() {
let cmd = BridgeCommand::ClosePage {
target_id: TID.into(),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("ClosePage"));
}
#[test]
fn send_timeout_returns_err() {
let (sender, receiver) = bridge_channel(Duration::from_millis(10));
sender.send_fire_and_forget(BridgeCommand::GetTitle {
target_id: TID.into(),
});
let resp = sender.send(BridgeCommand::GetUrl {
target_id: TID.into(),
});
assert!(resp.result.is_err());
assert_eq!(resp.result.unwrap_err(), "bridge response timeout");
receiver.drain(noop_handler);
}
#[test]
fn multiple_sequential_send_process() {
let (sender, receiver) = bridge_channel(TIMEOUT);
let commands: Vec<BridgeCommand> = vec![
BridgeCommand::Navigate {
target_id: TID.into(),
url: "https://a.com".into(),
},
BridgeCommand::EvaluateJs {
target_id: TID.into(),
expression: "1+1".into(),
return_by_value: true,
},
BridgeCommand::GetTitle {
target_id: TID.into(),
},
];
for cmd in commands {
sender.send_fire_and_forget(cmd);
}
let mut results: Vec<String> = Vec::new();
loop {
let processed = receiver.try_process(|c| {
let label = match c {
BridgeCommand::Navigate { url, .. } => format!("nav:{}", url),
BridgeCommand::EvaluateJs { expression, .. } => format!("eval:{}", expression),
BridgeCommand::GetTitle { .. } => "title".into(),
_ => "other".into(),
};
results.push(label);
ok_response(Value::Null)
});
if !processed {
break;
}
}
assert_eq!(results.len(), 3);
assert!(results[0].starts_with("nav:"));
assert!(results[1].starts_with("eval:"));
assert_eq!(results[2], "title");
}
#[test]
fn list_worker_targets_construction() {
let cmd = BridgeCommand::ListWorkerTargets {
target_id: TID.into(),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("ListWorkerTargets"));
}
#[test]
fn get_worker_target_info_construction() {
let cmd = BridgeCommand::GetWorkerTargetInfo {
target_id: TID.into(),
worker_id: "worker-1".into(),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("GetWorkerTargetInfo"));
assert!(debug_str.contains("worker-1"));
}
#[test]
fn list_service_worker_registrations_construction() {
let cmd = BridgeCommand::ListServiceWorkerRegistrations {
target_id: TID.into(),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("ListServiceWorkerRegistrations"));
}
#[test]
fn get_service_worker_registration_info_construction() {
let cmd = BridgeCommand::GetServiceWorkerRegistrationInfo {
target_id: TID.into(),
registration_id: "sw-reg-1".into(),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("GetServiceWorkerRegistrationInfo"));
assert!(debug_str.contains("sw-reg-1"));
}
#[test]
fn terminate_service_worker_construction() {
let cmd = BridgeCommand::TerminateServiceWorker {
target_id: TID.into(),
registration_id: "sw-reg-1".into(),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("TerminateServiceWorker"));
}
#[test]
fn stop_service_worker_construction() {
let cmd = BridgeCommand::StopServiceWorker {
target_id: TID.into(),
registration_id: "sw-reg-1".into(),
};
let debug_str = format!("{:?}", cmd);
assert!(debug_str.contains("StopServiceWorker"));
}
#[test]
fn list_worker_targets_bridge_send_receive() {
let (sender, receiver) = bridge_channel(TIMEOUT);
sender.send_fire_and_forget(BridgeCommand::ListWorkerTargets {
target_id: TID.into(),
});
let processed = receiver.try_process(|cmd| {
match cmd {
BridgeCommand::ListWorkerTargets { .. } => ok_response(serde_json::json!({
"workerTargets": [
{ "targetId": "worker-1", "type": "worker", "url": "https://example.com/worker.js", "title": "" }
]
})),
_ => err_response("unexpected"),
}
});
assert!(processed, "ListWorkerTargets should be receivable");
}
#[test]
fn service_worker_registrations_bridge_roundtrip() {
let (sender, receiver) = bridge_channel(Duration::from_secs(2));
let keeper = sender.clone();
std::thread::spawn(move || {
let _keeper = keeper;
loop {
let handled = receiver.try_process(|cmd| match cmd {
BridgeCommand::ListServiceWorkerRegistrations { .. } => BridgeResponse {
result: Ok(serde_json::json!({
"registrations": [{
"registrationId": "/sw.js:https://example.com/",
"scriptURL": "https://example.com/sw.js",
"scope": "https://example.com/",
"state": "activated",
"fetchInterceptActive": true
}]
})),
},
_ => BridgeResponse {
result: Ok(serde_json::json!({})),
},
});
if !handled {
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
});
let resp = sender.send(BridgeCommand::ListServiceWorkerRegistrations {
target_id: TID.into(),
});
assert!(resp.result.is_ok());
let result = resp.result.unwrap();
let regs = result["registrations"].as_array().unwrap();
assert_eq!(regs.len(), 1);
assert_eq!(regs[0]["state"], "activated");
assert_eq!(regs[0]["fetchInterceptActive"], true);
}
}