use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use base64::Engine as B64Engine;
use serde_json::Value;
use buffr_core::{DownloadNotice, DownloadNoticeKind, DownloadNoticeQueue};
use buffr_downloads::{DownloadId, Downloads};
use buffr_engine::{PermissionsQueue, SharedOsrFrame, SharedOsrViewState};
use buffr_permissions::Capability;
use crate::context_menu::ContextMenuSink;
use crate::cdp::{
CdpCommand, CdpMessage, DispatchKeyEventParams, DispatchMouseEventParams, NavigateParams,
ScreencastFrameAckParams, SetDeviceMetricsParams, StartScreencastParams,
};
use crate::error::BlinkError;
use crate::ws::WsClient;
pub type UrlUpdateSink = Arc<Mutex<VecDeque<(String, String, String)>>>;
pub fn new_url_update_sink() -> UrlUpdateSink {
Arc::new(Mutex::new(VecDeque::new()))
}
pub type TitleMap = Arc<Mutex<HashMap<String, String>>>;
pub fn new_title_map() -> TitleMap {
Arc::new(Mutex::new(HashMap::new()))
}
pub enum Command {
BrowserCmd {
cmd: CdpCommand,
reply: Sender<Result<Value, BlinkError>>,
},
SessionCmd {
session_id: String,
cmd: CdpCommand,
reply: Sender<Result<Value, BlinkError>>,
},
Navigate {
session_id: String,
url: String,
reply: Sender<Result<Value, BlinkError>>,
},
Resize {
session_id: String,
width: u32,
height: u32,
},
MouseEvent {
session_id: String,
params: DispatchMouseEventParams,
},
KeyEvent {
session_id: String,
params: DispatchKeyEventParams,
},
SetZoom { session_id: String, level: f64 },
SetActiveSession {
session_id: Option<String>,
width: u32,
height: u32,
},
Shutdown,
}
pub const CDP_RESPONSE_TIMEOUT_SECS: u64 = 10;
pub const WORKER_CMD_CHANNEL_CAP: usize = 256;
#[allow(clippy::too_many_arguments)]
pub fn run(
mut ws: WsClient,
cmd_rx: Receiver<Command>,
osr_frame: SharedOsrFrame,
osr_view: SharedOsrViewState,
permissions_queue: PermissionsQueue,
perm_session_map: Arc<Mutex<HashMap<String, String>>>,
downloads: Option<Arc<Downloads>>,
notice_queue: Option<DownloadNoticeQueue>,
download_dir: PathBuf,
context_menu_sink: ContextMenuSink,
url_update_sink: UrlUpdateSink,
loading_state: Arc<Mutex<HashMap<String, bool>>>,
nav_count: Arc<Mutex<HashMap<String, usize>>>,
title_map: TitleMap,
) {
let mut pending: HashMap<u64, Sender<Result<Value, BlinkError>>> = HashMap::new();
let mut active_session: Option<String> = None;
let mut session_zoom: HashMap<String, f64> = HashMap::new();
let mut download_ids: HashMap<String, (DownloadId, String)> = HashMap::new();
tracing::debug!("CDP worker started");
loop {
loop {
match cmd_rx.try_recv() {
Ok(Command::Shutdown) => {
tracing::debug!("CDP worker: shutdown command received");
return;
}
Ok(Command::SetActiveSession {
session_id,
width,
height,
}) => {
if let Some(ref old) = active_session {
send_stop_screencast(&mut ws, old);
}
active_session = session_id;
if let Some(ref new_sess) = active_session {
send_start_screencast(&mut ws, new_sess, width.max(1), height.max(1));
}
}
Ok(Command::Navigate {
session_id,
url,
reply,
}) => {
let cmd = CdpCommand::new("Page.navigate", NavigateParams { url: &url })
.with_session(session_id);
let id = cmd.id;
if let Err(e) = ws.send_text(cmd.serialize()) {
let _ = reply.send(Err(e));
continue;
}
pending.insert(id, reply);
}
Ok(Command::Resize {
session_id,
width,
height,
}) => {
let cmd = CdpCommand::new(
"Page.setDeviceMetricsOverride",
SetDeviceMetricsParams {
width,
height,
device_scale_factor: 1.0,
mobile: false,
},
)
.with_session(session_id.clone());
let _ = ws.send_text(cmd.serialize());
if active_session.as_deref() == Some(&session_id) {
send_stop_screencast(&mut ws, &session_id);
send_start_screencast(&mut ws, &session_id, width.max(1), height.max(1));
}
}
Ok(Command::MouseEvent { session_id, params }) => {
let cmd = CdpCommand::new("Input.dispatchMouseEvent", params)
.with_session(session_id);
let _ = ws.send_text(cmd.serialize());
}
Ok(Command::KeyEvent { session_id, params }) => {
let cmd =
CdpCommand::new("Input.dispatchKeyEvent", params).with_session(session_id);
let _ = ws.send_text(cmd.serialize());
}
Ok(Command::SetZoom { session_id, level }) => {
let expr = format!(
"(document.body || document.documentElement).style.zoom = '{level}'"
);
let cmd = CdpCommand::new(
"Runtime.evaluate",
serde_json::json!({ "expression": expr }),
)
.with_session(session_id.clone());
let _ = ws.send_text(cmd.serialize());
if (level - 1.0_f64).abs() < f64::EPSILON {
session_zoom.remove(&session_id);
} else {
session_zoom.insert(session_id, level);
}
}
Ok(Command::BrowserCmd { cmd, reply }) => {
let id = cmd.id;
if let Err(e) = ws.send_text(cmd.serialize()) {
let _ = reply.send(Err(e));
continue;
}
pending.insert(id, reply);
}
Ok(Command::SessionCmd {
session_id,
cmd,
reply,
}) => {
let cmd = cmd.with_session(session_id);
let id = cmd.id;
if let Err(e) = ws.send_text(cmd.serialize()) {
let _ = reply.send(Err(e));
continue;
}
pending.insert(id, reply);
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
tracing::debug!("CDP worker: command channel closed — exiting");
return;
}
}
}
match ws.try_recv_text() {
Ok(None) => {
std::thread::sleep(Duration::from_millis(crate::ws::WS_POLL_INTERVAL_MS));
continue;
}
Err(e) => {
tracing::warn!(error = %e, "CDP worker: WS read error — exiting");
for (_, tx) in pending.drain() {
let _ = tx.send(Err(BlinkError::WsIo(e.to_string())));
}
return;
}
Ok(Some(text)) => match serde_json::from_str::<CdpMessage>(&text) {
Err(e) => {
tracing::debug!(error = %e, raw = %text, "CDP worker: unparse-able message");
}
Ok(msg) => {
dispatch_message(
msg,
&mut pending,
&session_zoom,
&mut ws,
&osr_frame,
&osr_view,
&permissions_queue,
&perm_session_map,
downloads.as_deref(),
notice_queue.as_ref(),
&download_dir,
&mut download_ids,
&context_menu_sink,
&url_update_sink,
&loading_state,
&nav_count,
&title_map,
);
}
},
}
}
}
fn send_start_screencast(ws: &mut WsClient, session_id: &str, width: u32, height: u32) {
let cmd = CdpCommand::new(
"Page.startScreencast",
StartScreencastParams {
format: "png",
quality: 100,
max_width: width,
max_height: height,
every_nth_frame: 1,
},
)
.with_session(session_id.to_owned());
tracing::debug!(session_id, width, height, "CDP: startScreencast");
let _ = ws.send_text(cmd.serialize());
}
fn send_stop_screencast(ws: &mut WsClient, session_id: &str) {
let cmd = CdpCommand::new_bare("Page.stopScreencast").with_session(session_id.to_owned());
tracing::debug!(session_id, "CDP: stopScreencast");
let _ = ws.send_text(cmd.serialize());
}
#[allow(clippy::too_many_arguments)]
fn dispatch_message(
msg: CdpMessage,
pending: &mut HashMap<u64, Sender<Result<Value, BlinkError>>>,
session_zoom: &HashMap<String, f64>,
ws: &mut WsClient,
osr_frame: &SharedOsrFrame,
_osr_view: &SharedOsrViewState,
permissions_queue: &PermissionsQueue,
perm_session_map: &Arc<Mutex<HashMap<String, String>>>,
downloads: Option<&Downloads>,
notice_queue: Option<&DownloadNoticeQueue>,
download_dir: &Path,
download_ids: &mut HashMap<String, (DownloadId, String)>,
context_menu_sink: &ContextMenuSink,
url_update_sink: &UrlUpdateSink,
loading_state: &Arc<Mutex<HashMap<String, bool>>>,
nav_count: &Arc<Mutex<HashMap<String, usize>>>,
title_map: &TitleMap,
) {
if let Some(id) = msg.id {
if let Some(tx) = pending.remove(&id) {
let result = if let Some(err) = msg.error {
Err(BlinkError::Protocol(format!(
"CDP error {}: {}",
err.code, err.message
)))
} else {
Ok(msg.result.unwrap_or(Value::Null))
};
let _ = tx.send(result);
}
return;
}
let Some(ref method) = msg.method else {
return;
};
tracing::debug!(method, "CDP event");
match method.as_str() {
"Page.screencastFrame" => {
handle_screencast_frame(&msg, ws, osr_frame);
}
"Page.frameNavigated" => {
if let Some(ref session_id) = msg.session_id
&& let Some(&level) = session_zoom.get(session_id)
{
let expr =
format!("(document.body || document.documentElement).style.zoom = '{level}'");
let cmd = CdpCommand::new(
"Runtime.evaluate",
serde_json::json!({ "expression": expr }),
)
.with_session(session_id.clone());
let _ = ws.send_text(cmd.serialize());
}
if let Some(ref session_id) = msg.session_id
&& let Some(ref params) = msg.params
{
if let Some(frame) = params.get("frame") {
let url = frame
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let title = frame
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
if !url.is_empty() {
tracing::debug!(
session_id,
url,
title,
"CDP: Page.frameNavigated — pushing url update"
);
if let Ok(mut sink) = url_update_sink.lock() {
sink.push_back((session_id.clone(), url, title));
}
}
}
if let Ok(mut map) = nav_count.lock() {
*map.entry(session_id.clone()).or_insert(0) += 1;
tracing::debug!(
session_id,
count = map.get(session_id).copied().unwrap_or(0),
"CDP: Page.frameNavigated — nav_count bumped"
);
}
}
}
"Page.lifecycleEvent" => {
if let Some(ref session_id) = msg.session_id
&& let Some(ref params) = msg.params
{
let event_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
tracing::debug!(session_id, event_name, "CDP: Page.lifecycleEvent");
match event_name {
"init" | "commit" => {
if let Ok(mut map) = loading_state.lock() {
map.insert(session_id.clone(), true);
}
}
"load" => {
if let Ok(mut map) = loading_state.lock() {
map.insert(session_id.clone(), false);
}
}
_ => {}
}
}
}
"Runtime.bindingCalled" => {
if let Some(ref params) = msg.params {
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
if name == "__buffrPermissionRequest" {
let payload = params.get("payload").and_then(|v| v.as_str()).unwrap_or("");
handle_permission_binding(
payload,
msg.session_id.as_deref(),
permissions_queue,
perm_session_map,
);
} else if name == "__buffrContextMenu" {
let payload = params.get("payload").and_then(|v| v.as_str()).unwrap_or("");
handle_context_menu_binding(payload, context_menu_sink);
}
}
}
"Browser.downloadWillBegin" => {
if let Some(ref params) = msg.params {
handle_download_will_begin(
params,
downloads,
notice_queue,
download_dir,
download_ids,
);
}
}
"Browser.downloadProgress" => {
if let Some(ref params) = msg.params {
handle_download_progress(
params,
downloads,
notice_queue,
download_dir,
download_ids,
);
}
}
"Target.targetInfoChanged" => {
if let Some(ref params) = msg.params
&& let Some(info) = params.get("targetInfo")
{
let target_id = info
.get("targetId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let title = info
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
if !target_id.is_empty() {
tracing::debug!(
target_id,
title,
"CDP: Target.targetInfoChanged — updating live title"
);
if let Ok(mut map) = title_map.lock() {
map.insert(target_id, title);
}
}
}
}
_ => {}
}
}
fn handle_permission_binding(
payload: &str,
session_id: Option<&str>,
permissions_queue: &PermissionsQueue,
perm_session_map: &Arc<Mutex<HashMap<String, String>>>,
) {
let v: serde_json::Value = match serde_json::from_str(payload) {
Ok(v) => v,
Err(e) => {
tracing::debug!(error = %e, payload, "blink-cdp: invalid permission binding payload");
return;
}
};
let id = match v.get("id").and_then(|v| v.as_str()) {
Some(s) => s.to_owned(),
None => {
tracing::debug!("blink-cdp: permission binding payload missing 'id'");
return;
}
};
let cap_str = v.get("capability").and_then(|v| v.as_str()).unwrap_or("");
let origin = v
.get("origin")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let cap: Capability =
crate::permissions::capability_from_str(cap_str).unwrap_or(Capability::Other(0));
tracing::debug!(
id,
cap_str,
origin,
"blink-cdp: permission request from JS shim"
);
if let Some(sess) = session_id
&& let Ok(mut map) = perm_session_map.lock()
{
map.insert(id.clone(), sess.to_owned());
}
let perm = buffr_engine::permissions::PendingPermission {
origin,
capabilities: vec![cap],
resolve_id: Some(id),
};
if let Ok(mut q) = permissions_queue.lock() {
q.push_back(perm);
}
}
fn handle_context_menu_binding(payload: &str, context_menu_sink: &ContextMenuSink) {
match crate::context_menu::parse_context_menu_binding(payload) {
Some(req) => {
tracing::debug!(
x = req.x,
y = req.y,
page_url = %req.page_url,
media_type = ?req.media_type,
"blink-cdp: context-menu request from JS shim"
);
if let Ok(mut q) = context_menu_sink.lock() {
q.push_back(req);
}
}
None => {
tracing::debug!(payload, "blink-cdp: invalid context-menu binding payload");
}
}
}
fn handle_download_will_begin(
params: &Value,
downloads: Option<&Downloads>,
notice_queue: Option<&DownloadNoticeQueue>,
download_dir: &Path,
download_ids: &mut HashMap<String, (DownloadId, String)>,
) {
let guid = match params.get("guid").and_then(|v| v.as_str()) {
Some(s) => s.to_owned(),
None => {
tracing::debug!("downloadWillBegin: missing guid");
return;
}
};
let url = params
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let filename = params
.get("suggestedFilename")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let target_path = download_dir.join(&filename);
tracing::debug!(
guid,
url,
filename,
path = %target_path.display(),
"blink-cdp: downloadWillBegin"
);
let cef_id = guid_to_cef_id(&guid);
if let Some(store) = downloads {
match store.record_started(cef_id, &url, &filename, None, None) {
Ok(id) => {
download_ids.insert(guid.clone(), (id, filename.clone()));
tracing::debug!(guid, row_id = id.0, "blink-cdp: download row inserted");
}
Err(e) => {
tracing::warn!(guid, error = %e, "blink-cdp: record_started failed");
}
}
}
if let Some(queue) = notice_queue {
buffr_core::push_download_notice(
queue,
DownloadNotice {
kind: DownloadNoticeKind::Started,
filename,
path: target_path.to_string_lossy().into_owned(),
created_at: Instant::now(),
},
);
}
}
fn handle_download_progress(
params: &Value,
downloads: Option<&Downloads>,
notice_queue: Option<&DownloadNoticeQueue>,
download_dir: &Path,
download_ids: &mut HashMap<String, (DownloadId, String)>,
) {
let guid = match params.get("guid").and_then(|v| v.as_str()) {
Some(s) => s.to_owned(),
None => {
tracing::debug!("downloadProgress: missing guid");
return;
}
};
let state = params
.get("state")
.and_then(|v| v.as_str())
.unwrap_or("inProgress");
let received = params
.get("receivedBytes")
.and_then(|v| v.as_f64())
.map(|f| f as u64)
.unwrap_or(0);
let total = params
.get("totalBytes")
.and_then(|v| v.as_f64())
.filter(|&f| f > 0.0)
.map(|f| f as u64);
tracing::debug!(guid, state, received, "blink-cdp: downloadProgress");
let (row_id, filename) = match download_ids.get(&guid) {
Some(entry) => (entry.0, entry.1.clone()),
None => {
tracing::debug!(
guid,
"blink-cdp: downloadProgress — no row for guid (ignoring)"
);
return;
}
};
match state {
"inProgress" => {
if let Some(store) = downloads
&& let Err(e) = store.update_progress(row_id, received, total)
{
tracing::warn!(guid, error = %e, "blink-cdp: update_progress failed");
}
}
"completed" => {
let full_path = download_dir.join(&filename);
if let Some(store) = downloads
&& let Err(e) = store.record_completed(row_id, &full_path)
{
tracing::warn!(guid, error = %e, "blink-cdp: record_completed failed");
}
download_ids.remove(&guid);
if let Some(queue) = notice_queue {
buffr_core::push_download_notice(
queue,
DownloadNotice {
kind: DownloadNoticeKind::Completed,
filename: filename.clone(),
path: full_path.to_string_lossy().into_owned(),
created_at: Instant::now(),
},
);
}
}
"canceled" => {
let full_path = download_dir.join(&filename);
if let Some(store) = downloads
&& let Err(e) = store.record_canceled(row_id)
{
tracing::warn!(guid, error = %e, "blink-cdp: record_canceled failed");
}
download_ids.remove(&guid);
if let Some(queue) = notice_queue {
buffr_core::push_download_notice(
queue,
DownloadNotice {
kind: DownloadNoticeKind::Failed,
filename: filename.clone(),
path: full_path.to_string_lossy().into_owned(),
created_at: Instant::now(),
},
);
}
}
other => {
tracing::debug!(guid, state = other, "blink-cdp: unknown download state");
}
}
}
fn guid_to_cef_id(guid: &str) -> u32 {
let mut hash: u32 = 2_166_136_261;
for byte in guid.bytes() {
hash ^= u32::from(byte);
hash = hash.wrapping_mul(16_777_619);
}
hash
}
fn handle_screencast_frame(msg: &CdpMessage, ws: &mut WsClient, osr_frame: &SharedOsrFrame) {
let params = match &msg.params {
Some(p) => p,
None => {
tracing::debug!("screencastFrame: missing params");
return;
}
};
let data_str = match params.get("data").and_then(|v| v.as_str()) {
Some(s) => s,
None => {
tracing::debug!("screencastFrame: missing data field");
return;
}
};
let screencast_session_id = params
.get("sessionId")
.and_then(|v| v.as_i64())
.unwrap_or(0);
decode_and_write_frame(data_str, osr_frame);
let ack = CdpCommand::new(
"Page.screencastFrameAck",
ScreencastFrameAckParams {
session_id: screencast_session_id,
},
);
let ack = match &msg.session_id {
Some(s) => ack.with_session(s.clone()),
None => ack,
};
tracing::debug!(screencast_session_id, "CDP: screencastFrameAck");
let _ = ws.send_text(ack.serialize());
}
pub(crate) fn decode_and_write_frame(b64: &str, osr_frame: &SharedOsrFrame) {
let data = match base64::engine::general_purpose::STANDARD.decode(b64) {
Ok(d) => d,
Err(e) => {
tracing::debug!(error = %e, "screencastFrame base64 decode failed");
return;
}
};
let img = match image::load_from_memory_with_format(&data, image::ImageFormat::Png) {
Ok(i) => i,
Err(e) => {
tracing::debug!(error = %e, "screencastFrame PNG decode failed");
return;
}
};
let rgba = img.into_rgba8();
let width = rgba.width();
let height = rgba.height();
let mut bgra = rgba.into_raw();
for chunk in bgra.chunks_exact_mut(4) {
chunk.swap(0, 2); }
if let Ok(mut frame) = osr_frame.lock() {
frame.width = width;
frame.height = height;
frame.pixels = bgra;
frame.generation = frame.generation.wrapping_add(1);
frame.needs_fresh = false;
tracing::debug!(
width,
height,
generation = frame.generation,
"OSR frame updated"
);
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use base64::Engine as _;
use buffr_engine::OsrFrame;
use super::*;
fn make_png_bytes(r: u8, g: u8, b: u8) -> Vec<u8> {
use image::{ImageBuffer, Rgba};
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_fn(2, 2, |_, _| Rgba([r, g, b, 255]));
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png)
.expect("encode png");
buf.into_inner()
}
#[test]
fn screencast_frame_decode_writes_bgra() {
let png_bytes = make_png_bytes(255, 0, 0);
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
let osr_frame: SharedOsrFrame = Arc::new(Mutex::new(OsrFrame::new(1, 1)));
decode_and_write_frame(&b64, &osr_frame);
let frame = osr_frame.lock().unwrap();
assert_eq!(frame.width, 2);
assert_eq!(frame.height, 2);
assert_eq!(&frame.pixels[0..4], &[0u8, 0, 255, 255], "BGRA swap");
for chunk in frame.pixels.chunks_exact(4) {
assert_eq!(chunk, &[0u8, 0, 255, 255]);
}
assert_eq!(frame.generation, 1);
assert!(!frame.needs_fresh);
}
#[test]
fn screencast_ack_message_shape() {
let ack = CdpCommand::new(
"Page.screencastFrameAck",
ScreencastFrameAckParams { session_id: 42 },
)
.with_session("sess-abc".to_owned());
let json = ack.serialize();
let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(v["method"], "Page.screencastFrameAck");
assert_eq!(v["params"]["sessionId"], 42);
assert_eq!(v["sessionId"], "sess-abc");
assert!(v["id"].as_u64().unwrap_or(0) > 0);
}
#[test]
fn decode_bad_base64_is_silent() {
let osr_frame: SharedOsrFrame = Arc::new(Mutex::new(OsrFrame::new(4, 4)));
decode_and_write_frame("not-valid-base64!!!", &osr_frame);
let frame = osr_frame.lock().unwrap();
assert_eq!(frame.generation, 0);
}
#[test]
fn download_ids_map_tuple_stores_filename() {
use buffr_downloads::DownloadId;
let mut download_ids: HashMap<String, (DownloadId, String)> = HashMap::new();
download_ids.insert(
"guid-abc".to_owned(),
(DownloadId(1), "report.pdf".to_owned()),
);
let (id, filename) = download_ids.get("guid-abc").cloned().unwrap();
assert_eq!(id, DownloadId(1));
assert_eq!(filename, "report.pdf");
}
#[test]
fn download_progress_full_path_construction() {
use std::path::Path;
let download_dir = Path::new("/home/user/Downloads");
let filename = "video.mp4";
let full_path = download_dir.join(filename);
let expected = download_dir.join("video.mp4");
assert_eq!(full_path, expected);
}
#[test]
fn url_update_sink_fifo_order() {
let sink = new_url_update_sink();
{
let mut guard = sink.lock().unwrap();
guard.push_back(("s1".into(), "https://a.example".into(), "A".into()));
guard.push_back(("s2".into(), "https://b.example".into(), "B".into()));
}
let items: Vec<_> = sink.lock().unwrap().drain(..).collect();
assert_eq!(items[0].0, "s1");
assert_eq!(items[1].0, "s2");
assert!(sink.lock().unwrap().is_empty());
}
#[allow(clippy::type_complexity)]
fn make_test_sinks() -> (
PermissionsQueue,
Arc<Mutex<HashMap<String, String>>>,
ContextMenuSink,
UrlUpdateSink,
Arc<Mutex<HashMap<String, bool>>>,
Arc<Mutex<HashMap<String, usize>>>,
TitleMap,
SharedOsrFrame,
SharedOsrViewState,
) {
use buffr_engine::{OsrFrame, OsrViewState};
(
buffr_engine::permissions::new_queue(),
Arc::new(Mutex::new(HashMap::new())),
crate::context_menu::new_context_menu_sink(),
new_url_update_sink(),
Arc::new(Mutex::new(HashMap::new())),
Arc::new(Mutex::new(HashMap::new())),
new_title_map(),
Arc::new(Mutex::new(OsrFrame::new(1, 1))),
Arc::new(OsrViewState::new()),
)
}
fn make_event(method: &str, params: serde_json::Value) -> CdpMessage {
serde_json::from_str(
&serde_json::to_string(&serde_json::json!({
"method": method,
"params": params,
}))
.unwrap(),
)
.unwrap()
}
fn make_session_event(method: &str, session_id: &str, params: serde_json::Value) -> CdpMessage {
serde_json::from_str(
&serde_json::to_string(&serde_json::json!({
"method": method,
"sessionId": session_id,
"params": params,
}))
.unwrap(),
)
.unwrap()
}
#[test]
fn dispatch_binding_called_permission_happy_path() {
let payload = serde_json::json!({
"id": "req-1",
"capability": "geolocation",
"origin": "https://example.com",
})
.to_string();
let permissions_queue = buffr_engine::permissions::new_queue();
let perm_session_map: Arc<Mutex<HashMap<String, String>>> =
Arc::new(Mutex::new(HashMap::new()));
handle_permission_binding(
&payload,
Some("sess-abc"),
&permissions_queue,
&perm_session_map,
);
let guard = permissions_queue.lock().unwrap();
assert_eq!(guard.len(), 1, "one permission request queued");
let perm = guard.front().unwrap();
assert_eq!(perm.resolve_id.as_deref(), Some("req-1"));
assert_eq!(perm.origin, "https://example.com");
drop(guard);
let sm = perm_session_map.lock().unwrap();
assert_eq!(sm.get("req-1").map(String::as_str), Some("sess-abc"));
}
#[test]
fn dispatch_binding_called_permission_malformed_no_id() {
let payload = serde_json::json!({
"capability": "geolocation",
"origin": "https://example.com",
})
.to_string();
let permissions_queue = buffr_engine::permissions::new_queue();
let perm_session_map: Arc<Mutex<HashMap<String, String>>> =
Arc::new(Mutex::new(HashMap::new()));
handle_permission_binding(
&payload,
Some("sess-abc"),
&permissions_queue,
&perm_session_map,
);
assert!(permissions_queue.lock().unwrap().is_empty());
}
#[test]
fn dispatch_binding_called_context_menu_happy_path() {
let payload = serde_json::json!({
"x": 100,
"y": 200,
"pageUrl": "https://example.com",
"mediaType": "none",
"linkUrl": null,
"srcUrl": null,
"altText": null,
})
.to_string();
let sink = crate::context_menu::new_context_menu_sink();
handle_context_menu_binding(&payload, &sink);
let guard = sink.lock().unwrap();
assert_eq!(guard.len(), 1, "one context-menu request queued");
let req = guard.front().unwrap();
assert_eq!(req.x, 100);
assert_eq!(req.y, 200);
}
#[test]
fn dispatch_binding_called_context_menu_malformed() {
let sink = crate::context_menu::new_context_menu_sink();
handle_context_menu_binding("not-json", &sink);
assert!(sink.lock().unwrap().is_empty());
}
#[test]
fn lifecycle_event_init_sets_loading_true() {
let (pq, psm, cms, uu, loading_state, nav_count, tm, osr, view) = make_test_sinks();
let _ = (pq, psm, cms, uu, nav_count, osr, view);
let msg = make_session_event(
"Page.lifecycleEvent",
"sess-1",
serde_json::json!({"name": "init"}),
);
if let Some(ref session_id) = msg.session_id
&& let Some(ref params) = msg.params
{
let event_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
if event_name == "init"
&& let Ok(mut map) = loading_state.lock()
{
map.insert(session_id.clone(), true);
}
}
let map = loading_state.lock().unwrap();
assert_eq!(map.get("sess-1").copied(), Some(true));
drop(map);
drop(tm);
}
#[test]
fn lifecycle_event_load_sets_loading_false() {
let (pq, psm, cms, uu, loading_state, nav_count, tm, osr, view) = make_test_sinks();
let _ = (pq, psm, cms, uu, nav_count, osr, view);
loading_state
.lock()
.unwrap()
.insert("sess-2".to_owned(), true);
let msg = make_session_event(
"Page.lifecycleEvent",
"sess-2",
serde_json::json!({"name": "load"}),
);
if let Some(ref session_id) = msg.session_id
&& let Some(ref params) = msg.params
{
let event_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
if event_name == "load"
&& let Ok(mut map) = loading_state.lock()
{
map.insert(session_id.clone(), false);
}
}
let map = loading_state.lock().unwrap();
assert_eq!(map.get("sess-2").copied(), Some(false));
drop(map);
drop(tm);
}
#[test]
fn target_info_changed_updates_title_map() {
let title_map = new_title_map();
let msg = make_event(
"Target.targetInfoChanged",
serde_json::json!({
"targetInfo": {
"targetId": "tgt-1",
"title": "Hello World",
"url": "https://example.com",
"type": "page",
}
}),
);
if let Some(ref params) = msg.params
&& let Some(info) = params.get("targetInfo")
{
let target_id = info
.get("targetId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
let title = info
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
if !target_id.is_empty() {
title_map.lock().unwrap().insert(target_id, title);
}
}
let map = title_map.lock().unwrap();
assert_eq!(map.get("tgt-1").map(String::as_str), Some("Hello World"));
}
#[test]
fn target_info_changed_missing_target_info_is_noop() {
let title_map = new_title_map();
let msg = make_event(
"Target.targetInfoChanged",
serde_json::json!({ "something": "else" }),
);
if let Some(ref params) = msg.params
&& let Some(info) = params.get("targetInfo")
{
let target_id = info
.get("targetId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
if !target_id.is_empty() {
title_map.lock().unwrap().insert(target_id, "x".to_owned());
}
}
assert!(title_map.lock().unwrap().is_empty());
}
#[test]
fn dispatch_unknown_method_is_noop() {
let (pq, psm, cms, uu, loading, nav, tm, _, _) = make_test_sinks();
let msg = make_event("Some.UnknownMethod", serde_json::json!({"data": 42}));
assert_eq!(msg.method.as_deref(), Some("Some.UnknownMethod"));
assert!(pq.lock().unwrap().is_empty());
assert!(psm.lock().unwrap().is_empty());
assert!(cms.lock().unwrap().is_empty());
assert!(uu.lock().unwrap().is_empty());
assert!(loading.lock().unwrap().is_empty());
assert!(nav.lock().unwrap().is_empty());
assert!(tm.lock().unwrap().is_empty());
}
#[test]
fn response_message_routes_to_pending_sender() {
let mut pending: HashMap<u64, std::sync::mpsc::Sender<Result<Value, BlinkError>>> =
HashMap::new();
let (tx, rx) = std::sync::mpsc::channel();
pending.insert(42, tx);
let msg: CdpMessage =
serde_json::from_str(r#"{"id": 42, "result": {"frameId": "fr-1"}}"#).unwrap();
if let Some(id) = msg.id
&& let Some(sender) = pending.remove(&id)
{
let result = if let Some(err) = msg.error {
Err(BlinkError::Protocol(format!(
"CDP error {}: {}",
err.code, err.message
)))
} else {
Ok(msg.result.unwrap_or(Value::Null))
};
let _ = sender.send(result);
}
assert!(pending.is_empty());
let result = rx.try_recv().unwrap();
assert!(result.is_ok());
assert_eq!(result.unwrap()["frameId"], "fr-1");
}
#[test]
fn response_message_unknown_id_is_noop() {
let mut pending: HashMap<u64, std::sync::mpsc::Sender<Result<Value, BlinkError>>> =
HashMap::new();
let msg: CdpMessage = serde_json::from_str(r#"{"id": 999, "result": {}}"#).unwrap();
if let Some(id) = msg.id
&& let Some(sender) = pending.remove(&id)
{
let _ = sender.send(Ok(Value::Null));
}
assert!(pending.is_empty());
}
#[test]
fn download_will_begin_missing_guid_is_noop() {
let mut download_ids: HashMap<String, (buffr_downloads::DownloadId, String)> =
HashMap::new();
let params = serde_json::json!({
"url": "https://example.com/file.pdf",
"suggestedFilename": "file.pdf",
});
handle_download_will_begin(
¶ms,
None, None, std::path::Path::new("/tmp"),
&mut download_ids,
);
assert!(download_ids.is_empty());
}
#[test]
fn download_progress_unknown_guid_is_noop() {
let mut download_ids: HashMap<String, (buffr_downloads::DownloadId, String)> =
HashMap::new();
let params = serde_json::json!({
"guid": "no-such-guid",
"state": "inProgress",
"receivedBytes": 1024,
"totalBytes": 4096,
});
handle_download_progress(
¶ms,
None,
None,
std::path::Path::new("/tmp"),
&mut download_ids,
);
assert!(download_ids.is_empty());
}
#[test]
fn new_title_map_starts_empty() {
let tm = new_title_map();
assert!(tm.lock().unwrap().is_empty());
}
#[test]
fn title_map_stores_multiple_targets() {
let tm = new_title_map();
{
let mut m = tm.lock().unwrap();
m.insert("t1".to_owned(), "Title 1".to_owned());
m.insert("t2".to_owned(), "Title 2".to_owned());
}
let m = tm.lock().unwrap();
assert_eq!(m.get("t1").map(String::as_str), Some("Title 1"));
assert_eq!(m.get("t2").map(String::as_str), Some("Title 2"));
}
}