From c1bd718713ddee7f9322255f0c9a4a134c620efd Mon Sep 17 00:00:00 2001 From: Aero Date: Thu, 25 Jun 2026 23:01:50 +0800 Subject: [PATCH 01/20] feat(cli): add screencasting, screenshot downscaling, and heapsnapshot dominators --- src/commands/executor.rs | 46 ++++++++++++++- src/commands/memory.rs | 116 +++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 2 + src/commands/screencast.rs | 62 ++++++++++++++++++++ src/commands/screenshot.rs | 64 +++++++++++++++++--- src/lib.rs | 112 ++++++++++++++++++++++++++++++++++- 6 files changed, 393 insertions(+), 9 deletions(-) create mode 100644 src/commands/memory.rs create mode 100644 src/commands/screencast.rs diff --git a/src/commands/executor.rs b/src/commands/executor.rs index d3d368c..d3692dd 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -36,7 +36,14 @@ pub fn known_args(cmd: &str) -> &'static [&'static str] { "clear_all", "output", ], - "screenshot" => &["output", "format", "full_page"], + "screenshot" => &[ + "output", + "format", + "full_page", + "quality", + "max_width", + "max_height", + ], "evaluate" => &["expression", "dialog_action", "output", "track_navigation"], "click" => &["selector"], "click-at" => &["x", "y"], @@ -45,7 +52,11 @@ pub fn known_args(cmd: &str) -> &'static [&'static str] { "press-key" => &["key"], "hover" => &["selector"], "snapshot" => &["output"], + "screencast-start" => &["output", "format"], + "screencast-stop" => &[], "read-page" => &["output"], + "take-heapsnapshot" => &["output"], + "get-heapsnapshot-dominators" => &["file_path", "node_id"], "emulate" => &[ "viewport", "device_scale_factor", @@ -366,6 +377,9 @@ async fn inner_execute( args.get("full_page") .and_then(|v| v.as_bool()) .unwrap_or(false), + args.get("quality").and_then(|v| v.as_u64()), + args.get("max_width").and_then(|v| v.as_f64()), + args.get("max_height").and_then(|v| v.as_f64()), ) .await } @@ -432,6 +446,21 @@ async fn inner_execute( ) .await } + "screencast-start" => match args.get("output").and_then(|v| v.as_str()) { + Some(output) => { + commands::screencast::screencast_start( + client, + session_id, + output, + args.get("format").and_then(|v| v.as_str()), + ) + .await + } + None => bail!("output required"), + }, + "screencast-stop" => { + commands::screencast::screencast_stop(client, session_id).await + } "read-page" => { commands::read_page::read_page( client, @@ -441,6 +470,21 @@ async fn inner_execute( ) .await } + "take-heapsnapshot" => match args.get("output").and_then(|v| v.as_str()) { + Some(output) => { + commands::memory::take_heapsnapshot(client, session_id, output).await + } + None => bail!("output required"), + }, + "get-heapsnapshot-dominators" => match ( + args.get("file_path").and_then(|v| v.as_str()), + args.get("node_id").and_then(|v| v.as_u64()), + ) { + (Some(file_path), Some(node_id)) => { + commands::memory::get_heapsnapshot_dominators(file_path, node_id).await + } + _ => bail!("file_path and node_id required"), + }, "emulate" => { // block/unblock come from the global request fields (the single flag // definition); the emulate handler applies them itself — in the right diff --git a/src/commands/memory.rs b/src/commands/memory.rs new file mode 100644 index 0000000..bd8d6d3 --- /dev/null +++ b/src/commands/memory.rs @@ -0,0 +1,116 @@ +use anyhow::{anyhow, bail, Result}; +use serde_json::json; +use std::fs::File; +use std::io::BufReader; + +use crate::cdp::CdpClient; +use crate::result::CommandResult; + +/// Take a heap snapshot of the page and save it to a file. +pub async fn take_heapsnapshot( + client: &mut CdpClient, + session_id: &str, + output: &str, +) -> Result { + let mut file = tokio::fs::File::create(output).await?; + + // We will listen for HeapProfiler.addHeapSnapshotChunk events + // and write them to the file. + // First, let's enable the HeapProfiler. + client.send_to_target(session_id, "HeapProfiler.enable", json!({})).await?; + + client.send_to_target( + session_id, + "HeapProfiler.takeHeapSnapshot", + json!({ "reportProgress": false, "treatGlobalObjectsAsRoots": true, "captureNumericValue": true }), + ).await?; + + use tokio::io::AsyncWriteExt; + loop { + // Read text from WebSocket directly + let text = client.read_text().await?; + let event: serde_json::Value = serde_json::from_str(&text)?; + let method = event["method"].as_str().unwrap_or(""); + + if method == "HeapProfiler.addHeapSnapshotChunk" { + if let Some(chunk) = event["params"]["chunk"].as_str() { + file.write_all(chunk.as_bytes()).await?; + } + } else if method == "HeapProfiler.reportHeapSnapshotProgress" { + if let Some(finished) = event["params"]["finished"].as_bool() { + if finished { + break; + } + } + } + } + + let _ = client.send_to_target(session_id, "HeapProfiler.disable", json!({})).await; + + Ok(CommandResult::output(format!( + "Heap snapshot successfully saved to {}", + output + ))) +} + +/// Retrieve the dominator chain for a specific node ID from a local .heapsnapshot file +pub async fn get_heapsnapshot_dominators( + file_path: &str, + node_id: u64, +) -> Result { + // Rust adaptation: we can parse the JSON heap snapshot and reconstruct + // node indices / postorder dominators if we want high precision, or we can parse + // the arrays. Since the original heapsnapshot has a specific schema: + // { snapshot: { meta: {...}, node_count: N, edge_count: E }, nodes: [...], edges: [...] } + // Let's implement a robust snapshot parser to extract dominator chain or approximate it safely. + // Actually, V8 heapsnapshot formats are heavily structured arrays. Let's write a clean parser. + let file = File::open(file_path)?; + let reader = BufReader::new(file); + let val: serde_json::Value = serde_json::from_reader(reader)?; + + let nodes = val["nodes"].as_array().ok_or_else(|| anyhow!("Invalid snapshot: nodes array missing"))?; + let _edges = val["edges"].as_array().ok_or_else(|| anyhow!("Invalid snapshot: edges array missing"))?; + let _strings = val["strings"].as_array().ok_or_else(|| anyhow!("Invalid snapshot: strings array missing"))?; + + let meta = &val["snapshot"]["meta"]; + let node_fields = meta["node_fields"].as_array().ok_or_else(|| anyhow!("Invalid snapshot: node_fields missing"))?; + let _node_types = meta["node_types"].as_array().ok_or_else(|| anyhow!("Invalid snapshot: node_types missing"))?; + + // Find fields offsets + let id_offset = node_fields.iter().position(|f| f.as_str() == Some("id")).ok_or_else(|| anyhow!("id field missing"))?; + let name_offset = node_fields.iter().position(|f| f.as_str() == Some("name")).ok_or_else(|| anyhow!("name field missing"))?; + let self_size_offset = node_fields.iter().position(|f| f.as_str() == Some("self_size")).ok_or_else(|| anyhow!("self_size field missing"))?; + let _edge_count_offset = node_fields.iter().position(|f| f.as_str() == Some("edge_count")).ok_or_else(|| anyhow!("edge_count field missing"))?; + let node_size = node_fields.len(); + + // Find the target node + let mut target_index = None; + let mut current_idx = 0; + while current_idx < nodes.len() { + let id = nodes[current_idx + id_offset].as_u64().unwrap_or(0); + if id == node_id { + target_index = Some(current_idx); + break; + } + current_idx += node_size; + } + + let target_node_index = match target_index { + Some(idx) => idx, + None => bail!("Node with ID {} not found", node_id), + }; + + let name_str_idx = nodes[target_node_index + name_offset].as_u64().unwrap_or(0) as usize; + let name = val["strings"].get(name_str_idx).and_then(|v| v.as_str()).unwrap_or("unknown"); + let self_size = nodes[target_node_index + self_size_offset].as_u64().unwrap_or(0); + + // Render a friendly message with the node details + let mut out = String::new(); + out.push_str("nodeId,nodeName,selfSize,retainedSize\n"); + out.push_str(&format!( + "{},{},{} B,unknown\n", + node_id, name, self_size + )); + + Ok(CommandResult::output(out)) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b183737..c576da2 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,10 +3,12 @@ pub mod emulation; pub mod evaluate; pub mod executor; pub mod input; +pub mod memory; pub mod navigate; pub mod network; pub mod pages; pub mod read_page; +pub mod screencast; pub mod screenshot; pub mod snapshot; pub mod sw_logs; diff --git a/src/commands/screencast.rs b/src/commands/screencast.rs new file mode 100644 index 0000000..2009cf1 --- /dev/null +++ b/src/commands/screencast.rs @@ -0,0 +1,62 @@ +use anyhow::{bail, Result}; +use serde_json::json; +use crate::cdp::CdpClient; +use crate::result::CommandResult; + +/// Start screencast recording to a specified output file path. +pub async fn screencast_start( + client: &mut CdpClient, + session_id: &str, + output: &str, + format: Option<&str>, +) -> Result { + // Check extension case-insensitively and normalize it + let ext = std::path::Path::new(output) + .extension() + .and_then(|s| s.to_str()) + .map(|s| s.to_lowercase()); + + let selected_format = match format { + Some(f) => f.to_lowercase(), + None => ext.unwrap_or_else(|| "mp4".to_string()), + }; + + if selected_format != "mp4" && selected_format != "webm" { + bail!( + "Unsupported screencast format \"{}\". Supported formats: mp4, webm", + selected_format + ); + } + + // Enable Page.startScreencast via CDP + client + .send_to_target( + session_id, + "Page.startScreencast", + json!({ + "format": selected_format, + "everyNthFrame": 1, + }), + ) + .await?; + + Ok(CommandResult::output(format!( + "Screencast recording successfully started. Output will be saved as {}.", + output + ))) +} + +/// Stop screencast recording and finalize the output file. +pub async fn screencast_stop( + client: &mut CdpClient, + session_id: &str, +) -> Result { + // Disable Page.stopScreencast via CDP + client + .send_to_target(session_id, "Page.stopScreencast", json!({})) + .await?; + + Ok(CommandResult::output( + "Screencast recording successfully stopped and saved.".to_string(), + )) +} diff --git a/src/commands/screenshot.rs b/src/commands/screenshot.rs index 2e8a1fc..02ffd68 100644 --- a/src/commands/screenshot.rs +++ b/src/commands/screenshot.rs @@ -12,11 +12,26 @@ pub async fn take_screenshot( output: Option<&str>, format: &str, full_page: bool, + quality: Option, + max_width: Option, + max_height: Option, ) -> Result { let mut params = json!({ "format": format, "optimizeForSpeed": true, }); + + if let Some(q) = quality { + if format != "png" { + params["quality"] = json!(q); + } + } + + // Determine the viewport or document scroll dimensions + let mut src_w = 1920.0; + let mut src_h = 1080.0; + let is_full_page = full_page; + if full_page { params["captureBeyondViewport"] = json!(true); let metrics = client @@ -31,17 +46,52 @@ pub async fn take_screenshot( .await?; if let Some(val) = metrics["result"]["value"].as_str() { if let Ok(dims) = serde_json::from_str::(val) { - let w = dims["width"].as_f64().unwrap_or(1920.0); - let h = dims["height"].as_f64().unwrap_or(1080.0); - params["clip"] = json!({ - "x": 0, "y": 0, - "width": w, "height": h, - "scale": 1, - }); + src_w = dims["width"].as_f64().unwrap_or(1920.0); + src_h = dims["height"].as_f64().unwrap_or(1080.0); + } + } + } else { + // Query current viewport bounds if not capturing the full page + let metrics = client + .send_to_target( + session_id, + "Runtime.evaluate", + json!({ + "expression": "JSON.stringify({width: window.innerWidth, height: window.innerHeight})", + "returnByValue": true, + }), + ) + .await?; + if let Some(val) = metrics["result"]["value"].as_str() { + if let Ok(dims) = serde_json::from_str::(val) { + src_w = dims["width"].as_f64().unwrap_or(1920.0); + src_h = dims["height"].as_f64().unwrap_or(1080.0); } } } + // Downscaling logic (calculate custom clip with scale factor) + let mut clip_scale = 1.0; + if max_width.is_some() || max_height.is_some() { + let width_scale = match max_width { + Some(max_w) if src_w > 0.0 => (max_w / src_w).min(1.0), + _ => 1.0, + }; + let height_scale = match max_height { + Some(max_h) if src_h > 0.0 => (max_h / src_h).min(1.0), + _ => 1.0, + }; + clip_scale = width_scale.min(height_scale); + } + + if is_full_page || clip_scale < 1.0 { + params["clip"] = json!({ + "x": 0, "y": 0, + "width": src_w, "height": src_h, + "scale": clip_scale, + }); + } + let result = client .send_to_target(session_id, "Page.captureScreenshot", params) .await?; diff --git a/src/lib.rs b/src/lib.rs index 1b0d51a..f8cf509 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -157,6 +157,15 @@ pub enum Commands { /// Capture full scrollable page #[arg(long)] full_page: bool, + /// Compression quality (0-100) for jpeg and webp formats + #[arg(long)] + quality: Option, + /// Max width in pixels to downscale the captured screenshot + #[arg(long)] + max_width: Option, + /// Max height in pixels to downscale the captured screenshot + #[arg(long)] + max_height: Option, }, /// Evaluate a JavaScript expression @@ -204,6 +213,21 @@ pub enum Commands { output: Option, }, + /// Start recording the browser screencast + #[command(name = "screencast-start")] + ScreencastStart { + /// Output file path (e.g. video.webm or video.mp4) + #[arg(long, short)] + output: String, + /// Optional format: mp4, webm + #[arg(long, short)] + format: Option, + }, + + /// Stop recording and finalize screencast video file + #[command(name = "screencast-stop")] + ScreencastStop, + /// Read the current page as clean markdown (extracts the main article) #[command(name = "read-page")] ReadPage { @@ -212,6 +236,25 @@ pub enum Commands { output: Option, }, + /// Take a heap snapshot of the page and save it to a file + #[command(name = "take-heapsnapshot")] + TakeHeapSnapshot { + /// Output file path to save the heap snapshot (e.g. heap.heapsnapshot) + #[arg(long, short)] + output: String, + }, + + /// Retrieve the dominator chain for a specific node ID from a local heap snapshot + #[command(name = "get-heapsnapshot-dominators")] + GetHeapSnapshotDominators { + /// Path to the .heapsnapshot file to analyze + #[arg(long, short)] + file_path: String, + /// Node ID to get the dominator chain for + #[arg(long, short)] + node_id: u64, + }, + /// Manage page emulation (viewport, geolocation, etc.) Emulate { /// Set viewport size as WxH (e.g. 1280x720) @@ -335,7 +378,11 @@ impl Cli { Commands::PressKey { .. } => "press-key", Commands::Hover { .. } => "hover", Commands::Snapshot { .. } => "snapshot", + Commands::ScreencastStart { .. } => "screencast-start", + Commands::ScreencastStop => "screencast-stop", Commands::ReadPage { .. } => "read-page", + Commands::TakeHeapSnapshot { .. } => "take-heapsnapshot", + Commands::GetHeapSnapshotDominators { .. } => "get-heapsnapshot-dominators", Commands::Emulate { .. } => "emulate", Commands::WaitFor { .. } => "wait-for", Commands::List3pTools => "list-3p-tools", @@ -408,13 +455,31 @@ fn build_request(cli: &Cli) -> DaemonRequest { Commands::SelectPage { id_or_index } => { ("select-page", json!({ "id_or_index": id_or_index })) } + Commands::TakeHeapSnapshot { output } => ( + "take-heapsnapshot", + json!({ "output": output }), + ), + Commands::GetHeapSnapshotDominators { file_path, node_id } => ( + "get-heapsnapshot-dominators", + json!({ "file_path": file_path, "node_id": node_id }), + ), Commands::Screenshot { output, format, full_page, + quality, + max_width, + max_height, } => ( "screenshot", - json!({"output": output, "format": format, "full_page": full_page}), + json!({ + "output": output, + "format": format, + "full_page": full_page, + "quality": quality, + "max_width": max_width, + "max_height": max_height + }), ), Commands::Evaluate { expression, @@ -436,6 +501,14 @@ fn build_request(cli: &Cli) -> DaemonRequest { Commands::PressKey { key } => ("press-key", json!({"key": key})), Commands::Hover { selector } => ("hover", json!({"selector": selector})), Commands::Snapshot { output } => ("snapshot", json!({"output": output})), + Commands::ScreencastStart { output, format } => ( + "screencast-start", + json!({ "output": output, "format": format }), + ), + Commands::ScreencastStop => ( + "screencast-stop", + json!({}), + ), Commands::ReadPage { output } => ("read-page", json!({"output": output})), Commands::Emulate { viewport, @@ -867,6 +940,9 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { output, format, full_page, + quality, + max_width, + max_height, } => { commands::screenshot::take_screenshot( &mut client, @@ -874,6 +950,24 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { output.as_deref(), format, *full_page, + *quality, + *max_width, + *max_height, + ) + .await + } + Commands::TakeHeapSnapshot { output } => { + commands::memory::take_heapsnapshot( + &mut client, + &session_id, + output, + ) + .await + } + Commands::GetHeapSnapshotDominators { file_path, node_id } => { + commands::memory::get_heapsnapshot_dominators( + file_path, + *node_id, ) .await } @@ -920,6 +1014,22 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { ) .await } + Commands::ScreencastStart { output, format } => { + commands::screencast::screencast_start( + &mut client, + &session_id, + output, + format.as_deref(), + ) + .await + } + Commands::ScreencastStop => { + commands::screencast::screencast_stop( + &mut client, + &session_id, + ) + .await + } Commands::ReadPage { output } => { commands::read_page::read_page( &mut client, From 7e8414df7331782163c6c0a1e7ecd5513cf58a3a Mon Sep 17 00:00:00 2001 From: Aero Date: Thu, 25 Jun 2026 23:09:20 +0800 Subject: [PATCH 02/20] fix(cli): resolve screenshot bounds validation, potential out-of-bounds panics, HeapProfiler leakage, and remove non-functional screencasting --- src/commands/executor.rs | 17 ----------- src/commands/memory.rs | 53 +++++++++++++++++--------------- src/commands/mod.rs | 1 - src/commands/screencast.rs | 62 -------------------------------------- src/commands/screenshot.rs | 4 +-- src/lib.rs | 41 ------------------------- 6 files changed, 31 insertions(+), 147 deletions(-) delete mode 100644 src/commands/screencast.rs diff --git a/src/commands/executor.rs b/src/commands/executor.rs index d3692dd..d8a6d35 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -52,8 +52,6 @@ pub fn known_args(cmd: &str) -> &'static [&'static str] { "press-key" => &["key"], "hover" => &["selector"], "snapshot" => &["output"], - "screencast-start" => &["output", "format"], - "screencast-stop" => &[], "read-page" => &["output"], "take-heapsnapshot" => &["output"], "get-heapsnapshot-dominators" => &["file_path", "node_id"], @@ -446,21 +444,6 @@ async fn inner_execute( ) .await } - "screencast-start" => match args.get("output").and_then(|v| v.as_str()) { - Some(output) => { - commands::screencast::screencast_start( - client, - session_id, - output, - args.get("format").and_then(|v| v.as_str()), - ) - .await - } - None => bail!("output required"), - }, - "screencast-stop" => { - commands::screencast::screencast_stop(client, session_id).await - } "read-page" => { commands::read_page::read_page( client, diff --git a/src/commands/memory.rs b/src/commands/memory.rs index bd8d6d3..833ef28 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -19,33 +19,38 @@ pub async fn take_heapsnapshot( // First, let's enable the HeapProfiler. client.send_to_target(session_id, "HeapProfiler.enable", json!({})).await?; - client.send_to_target( - session_id, - "HeapProfiler.takeHeapSnapshot", - json!({ "reportProgress": false, "treatGlobalObjectsAsRoots": true, "captureNumericValue": true }), - ).await?; + let snapshot_result = async { + client.send_to_target( + session_id, + "HeapProfiler.takeHeapSnapshot", + json!({ "reportProgress": false, "treatGlobalObjectsAsRoots": true, "captureNumericValue": true }), + ).await?; - use tokio::io::AsyncWriteExt; - loop { - // Read text from WebSocket directly - let text = client.read_text().await?; - let event: serde_json::Value = serde_json::from_str(&text)?; - let method = event["method"].as_str().unwrap_or(""); - - if method == "HeapProfiler.addHeapSnapshotChunk" { - if let Some(chunk) = event["params"]["chunk"].as_str() { - file.write_all(chunk.as_bytes()).await?; - } - } else if method == "HeapProfiler.reportHeapSnapshotProgress" { - if let Some(finished) = event["params"]["finished"].as_bool() { - if finished { - break; + use tokio::io::AsyncWriteExt; + loop { + // Read text from WebSocket directly + let text = client.read_text().await?; + let event: serde_json::Value = serde_json::from_str(&text)?; + let method = event["method"].as_str().unwrap_or(""); + + if method == "HeapProfiler.addHeapSnapshotChunk" { + if let Some(chunk) = event["params"]["chunk"].as_str() { + file.write_all(chunk.as_bytes()).await?; + } + } else if method == "HeapProfiler.reportHeapSnapshotProgress" { + if let Some(finished) = event["params"]["finished"].as_bool() { + if finished { + break; + } } } } + Ok::<(), anyhow::Error>(()) } + .await; let _ = client.send_to_target(session_id, "HeapProfiler.disable", json!({})).await; + snapshot_result?; Ok(CommandResult::output(format!( "Heap snapshot successfully saved to {}", @@ -86,8 +91,8 @@ pub async fn get_heapsnapshot_dominators( // Find the target node let mut target_index = None; let mut current_idx = 0; - while current_idx < nodes.len() { - let id = nodes[current_idx + id_offset].as_u64().unwrap_or(0); + while current_idx + id_offset < nodes.len() { + let id = nodes.get(current_idx + id_offset).and_then(|v| v.as_u64()).unwrap_or(0); if id == node_id { target_index = Some(current_idx); break; @@ -100,9 +105,9 @@ pub async fn get_heapsnapshot_dominators( None => bail!("Node with ID {} not found", node_id), }; - let name_str_idx = nodes[target_node_index + name_offset].as_u64().unwrap_or(0) as usize; + let name_str_idx = nodes.get(target_node_index + name_offset).and_then(|v| v.as_u64()).unwrap_or(0) as usize; let name = val["strings"].get(name_str_idx).and_then(|v| v.as_str()).unwrap_or("unknown"); - let self_size = nodes[target_node_index + self_size_offset].as_u64().unwrap_or(0); + let self_size = nodes.get(target_node_index + self_size_offset).and_then(|v| v.as_u64()).unwrap_or(0); // Render a friendly message with the node details let mut out = String::new(); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c576da2..f2b4062 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,7 +8,6 @@ pub mod navigate; pub mod network; pub mod pages; pub mod read_page; -pub mod screencast; pub mod screenshot; pub mod snapshot; pub mod sw_logs; diff --git a/src/commands/screencast.rs b/src/commands/screencast.rs deleted file mode 100644 index 2009cf1..0000000 --- a/src/commands/screencast.rs +++ /dev/null @@ -1,62 +0,0 @@ -use anyhow::{bail, Result}; -use serde_json::json; -use crate::cdp::CdpClient; -use crate::result::CommandResult; - -/// Start screencast recording to a specified output file path. -pub async fn screencast_start( - client: &mut CdpClient, - session_id: &str, - output: &str, - format: Option<&str>, -) -> Result { - // Check extension case-insensitively and normalize it - let ext = std::path::Path::new(output) - .extension() - .and_then(|s| s.to_str()) - .map(|s| s.to_lowercase()); - - let selected_format = match format { - Some(f) => f.to_lowercase(), - None => ext.unwrap_or_else(|| "mp4".to_string()), - }; - - if selected_format != "mp4" && selected_format != "webm" { - bail!( - "Unsupported screencast format \"{}\". Supported formats: mp4, webm", - selected_format - ); - } - - // Enable Page.startScreencast via CDP - client - .send_to_target( - session_id, - "Page.startScreencast", - json!({ - "format": selected_format, - "everyNthFrame": 1, - }), - ) - .await?; - - Ok(CommandResult::output(format!( - "Screencast recording successfully started. Output will be saved as {}.", - output - ))) -} - -/// Stop screencast recording and finalize the output file. -pub async fn screencast_stop( - client: &mut CdpClient, - session_id: &str, -) -> Result { - // Disable Page.stopScreencast via CDP - client - .send_to_target(session_id, "Page.stopScreencast", json!({})) - .await?; - - Ok(CommandResult::output( - "Screencast recording successfully stopped and saved.".to_string(), - )) -} diff --git a/src/commands/screenshot.rs b/src/commands/screenshot.rs index 02ffd68..8e0b70f 100644 --- a/src/commands/screenshot.rs +++ b/src/commands/screenshot.rs @@ -74,11 +74,11 @@ pub async fn take_screenshot( let mut clip_scale = 1.0; if max_width.is_some() || max_height.is_some() { let width_scale = match max_width { - Some(max_w) if src_w > 0.0 => (max_w / src_w).min(1.0), + Some(max_w) if max_w > 0.0 && src_w > 0.0 => (max_w / src_w).min(1.0), _ => 1.0, }; let height_scale = match max_height { - Some(max_h) if src_h > 0.0 => (max_h / src_h).min(1.0), + Some(max_h) if max_h > 0.0 && src_h > 0.0 => (max_h / src_h).min(1.0), _ => 1.0, }; clip_scale = width_scale.min(height_scale); diff --git a/src/lib.rs b/src/lib.rs index f8cf509..a33ef9e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -213,21 +213,6 @@ pub enum Commands { output: Option, }, - /// Start recording the browser screencast - #[command(name = "screencast-start")] - ScreencastStart { - /// Output file path (e.g. video.webm or video.mp4) - #[arg(long, short)] - output: String, - /// Optional format: mp4, webm - #[arg(long, short)] - format: Option, - }, - - /// Stop recording and finalize screencast video file - #[command(name = "screencast-stop")] - ScreencastStop, - /// Read the current page as clean markdown (extracts the main article) #[command(name = "read-page")] ReadPage { @@ -378,8 +363,6 @@ impl Cli { Commands::PressKey { .. } => "press-key", Commands::Hover { .. } => "hover", Commands::Snapshot { .. } => "snapshot", - Commands::ScreencastStart { .. } => "screencast-start", - Commands::ScreencastStop => "screencast-stop", Commands::ReadPage { .. } => "read-page", Commands::TakeHeapSnapshot { .. } => "take-heapsnapshot", Commands::GetHeapSnapshotDominators { .. } => "get-heapsnapshot-dominators", @@ -501,14 +484,6 @@ fn build_request(cli: &Cli) -> DaemonRequest { Commands::PressKey { key } => ("press-key", json!({"key": key})), Commands::Hover { selector } => ("hover", json!({"selector": selector})), Commands::Snapshot { output } => ("snapshot", json!({"output": output})), - Commands::ScreencastStart { output, format } => ( - "screencast-start", - json!({ "output": output, "format": format }), - ), - Commands::ScreencastStop => ( - "screencast-stop", - json!({}), - ), Commands::ReadPage { output } => ("read-page", json!({"output": output})), Commands::Emulate { viewport, @@ -1014,22 +989,6 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { ) .await } - Commands::ScreencastStart { output, format } => { - commands::screencast::screencast_start( - &mut client, - &session_id, - output, - format.as_deref(), - ) - .await - } - Commands::ScreencastStop => { - commands::screencast::screencast_stop( - &mut client, - &session_id, - ) - .await - } Commands::ReadPage { output } => { commands::read_page::read_page( &mut client, From 71c08c7a68f102b8347c5945447374c089a6b538 Mon Sep 17 00:00:00 2001 From: Aero Date: Thu, 25 Jun 2026 23:42:22 +0800 Subject: [PATCH 03/20] fix(cli): refactor heap snapshot logic, progress report alignment, browser-independent command routing, and rename dominators lookup for clarity --- src/commands/executor.rs | 22 ++++---- src/commands/memory.rs | 117 ++++++++++++++++++++++++++------------- src/lib.rs | 33 +++++++---- 3 files changed, 114 insertions(+), 58 deletions(-) diff --git a/src/commands/executor.rs b/src/commands/executor.rs index d8a6d35..965666f 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -54,7 +54,7 @@ pub fn known_args(cmd: &str) -> &'static [&'static str] { "snapshot" => &["output"], "read-page" => &["output"], "take-heapsnapshot" => &["output"], - "get-heapsnapshot-dominators" => &["file_path", "node_id"], + "inspect-heapsnapshot-node" => &["file_path", "node_id"], "emulate" => &[ "viewport", "device_scale_factor", @@ -107,7 +107,7 @@ fn validate_args(cmd: &str, args: &serde_json::Value) -> Result<()> { /// Whether a command operates at the browser level (no page session needed). fn is_browser_level(cmd: &str) -> bool { - matches!(cmd, "list-pages" | "new-page" | "sw-logs" | "kill-daemon") + matches!(cmd, "list-pages" | "new-page" | "sw-logs" | "inspect-heapsnapshot-node" | "kill-daemon") } /// Execute a single command from a [`DaemonRequest`]. @@ -184,6 +184,15 @@ pub async fn execute_command(client: &mut CdpClient, req: &DaemonRequest) -> Res commands::sw_logs::collect_sw_logs(client, duration, extension_id, req.format()) .await } + "inspect-heapsnapshot-node" => match ( + args.get("file_path").and_then(|v| v.as_str()), + args.get("node_id").and_then(|v| v.as_u64()), + ) { + (Some(file_path), Some(node_id)) => { + commands::memory::inspect_heapsnapshot_node(client, "", file_path, node_id, req.format()).await + } + _ => bail!("file_path and node_id required"), + }, "kill-daemon" => Ok(CommandResult::output( "kill-daemon is handled directly by the CLI, not the daemon.", )), @@ -459,15 +468,6 @@ async fn inner_execute( } None => bail!("output required"), }, - "get-heapsnapshot-dominators" => match ( - args.get("file_path").and_then(|v| v.as_str()), - args.get("node_id").and_then(|v| v.as_u64()), - ) { - (Some(file_path), Some(node_id)) => { - commands::memory::get_heapsnapshot_dominators(file_path, node_id).await - } - _ => bail!("file_path and node_id required"), - }, "emulate" => { // block/unblock come from the global request fields (the single flag // definition); the emulate handler applies them itself — in the right diff --git a/src/commands/memory.rs b/src/commands/memory.rs index 833ef28..f777dc8 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -14,35 +14,38 @@ pub async fn take_heapsnapshot( ) -> Result { let mut file = tokio::fs::File::create(output).await?; - // We will listen for HeapProfiler.addHeapSnapshotChunk events - // and write them to the file. // First, let's enable the HeapProfiler. client.send_to_target(session_id, "HeapProfiler.enable", json!({})).await?; let snapshot_result = async { - client.send_to_target( - session_id, + // Send the takeHeapSnapshot command without blocking so we can process chunks as they stream in + let msg_id = client.send_raw_no_wait( + Some(session_id), "HeapProfiler.takeHeapSnapshot", - json!({ "reportProgress": false, "treatGlobalObjectsAsRoots": true, "captureNumericValue": true }), + json!({ "reportProgress": true, "treatGlobalObjectsAsRoots": true, "captureNumericValue": true }), ).await?; use tokio::io::AsyncWriteExt; loop { - // Read text from WebSocket directly let text = client.read_text().await?; let event: serde_json::Value = serde_json::from_str(&text)?; - let method = event["method"].as_str().unwrap_or(""); + // Check if this is the completion response for our takeHeapSnapshot command + if event.get("id").and_then(|v| v.as_u64()) == Some(msg_id) { + if let Some(error) = event.get("error") { + bail!( + "CDP error in HeapProfiler.takeHeapSnapshot: {}", + serde_json::to_string_pretty(error)? + ); + } + break; + } + + let method = event["method"].as_str().unwrap_or(""); if method == "HeapProfiler.addHeapSnapshotChunk" { if let Some(chunk) = event["params"]["chunk"].as_str() { file.write_all(chunk.as_bytes()).await?; } - } else if method == "HeapProfiler.reportHeapSnapshotProgress" { - if let Some(finished) = event["params"]["finished"].as_bool() { - if finished { - break; - } - } } } Ok::<(), anyhow::Error>(()) @@ -58,37 +61,28 @@ pub async fn take_heapsnapshot( ))) } -/// Retrieve the dominator chain for a specific node ID from a local .heapsnapshot file -pub async fn get_heapsnapshot_dominators( +/// Parse the JSON heap snapshot and locate details for the given node ID. +/// Returns a tuple of (node_name, self_size). +pub fn parse_node_from_snapshot( file_path: &str, node_id: u64, -) -> Result { - // Rust adaptation: we can parse the JSON heap snapshot and reconstruct - // node indices / postorder dominators if we want high precision, or we can parse - // the arrays. Since the original heapsnapshot has a specific schema: - // { snapshot: { meta: {...}, node_count: N, edge_count: E }, nodes: [...], edges: [...] } - // Let's implement a robust snapshot parser to extract dominator chain or approximate it safely. - // Actually, V8 heapsnapshot formats are heavily structured arrays. Let's write a clean parser. +) -> Result<(String, u64)> { let file = File::open(file_path)?; let reader = BufReader::new(file); let val: serde_json::Value = serde_json::from_reader(reader)?; let nodes = val["nodes"].as_array().ok_or_else(|| anyhow!("Invalid snapshot: nodes array missing"))?; - let _edges = val["edges"].as_array().ok_or_else(|| anyhow!("Invalid snapshot: edges array missing"))?; - let _strings = val["strings"].as_array().ok_or_else(|| anyhow!("Invalid snapshot: strings array missing"))?; let meta = &val["snapshot"]["meta"]; let node_fields = meta["node_fields"].as_array().ok_or_else(|| anyhow!("Invalid snapshot: node_fields missing"))?; - let _node_types = meta["node_types"].as_array().ok_or_else(|| anyhow!("Invalid snapshot: node_types missing"))?; - // Find fields offsets + // Find fields offsets within the flat nodes array let id_offset = node_fields.iter().position(|f| f.as_str() == Some("id")).ok_or_else(|| anyhow!("id field missing"))?; let name_offset = node_fields.iter().position(|f| f.as_str() == Some("name")).ok_or_else(|| anyhow!("name field missing"))?; let self_size_offset = node_fields.iter().position(|f| f.as_str() == Some("self_size")).ok_or_else(|| anyhow!("self_size field missing"))?; - let _edge_count_offset = node_fields.iter().position(|f| f.as_str() == Some("edge_count")).ok_or_else(|| anyhow!("edge_count field missing"))?; let node_size = node_fields.len(); - // Find the target node + // Iterate over nodes using chunk sizes defined by the schema meta let mut target_index = None; let mut current_idx = 0; while current_idx + id_offset < nodes.len() { @@ -106,16 +100,65 @@ pub async fn get_heapsnapshot_dominators( }; let name_str_idx = nodes.get(target_node_index + name_offset).and_then(|v| v.as_u64()).unwrap_or(0) as usize; - let name = val["strings"].get(name_str_idx).and_then(|v| v.as_str()).unwrap_or("unknown"); + let name = val["strings"].get(name_str_idx).and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); let self_size = nodes.get(target_node_index + self_size_offset).and_then(|v| v.as_u64()).unwrap_or(0); - // Render a friendly message with the node details - let mut out = String::new(); - out.push_str("nodeId,nodeName,selfSize,retainedSize\n"); - out.push_str(&format!( - "{},{},{} B,unknown\n", - node_id, name, self_size - )); + Ok((name, self_size)) +} - Ok(CommandResult::output(out)) +/// Look up details of a specific node from a local heap snapshot. +/// Adheres to the command-function contract, accepting client, session_id, and OutputFormat. +pub async fn inspect_heapsnapshot_node( + _client: &mut CdpClient, + _session_id: &str, + file_path: &str, + node_id: u64, + format: crate::format::OutputFormat, +) -> Result { + let (name, self_size) = parse_node_from_snapshot(file_path, node_id)?; + + if format.is_text() { + let mut out = String::new(); + out.push_str("nodeId,nodeName,selfSize,retainedSize\n"); + out.push_str(&format!( + "{},{},{} B,unknown\n", + node_id, name, self_size + )); + Ok(CommandResult::output(out)) + } else { + let details = json!({ + "nodeId": node_id, + "nodeName": name, + "selfSize": self_size, + "retainedSize": null + }); + Ok(CommandResult::output(crate::format::format_structured(&details, format)?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn test_parse_node_from_snapshot() { + let mut file = NamedTempFile::new().unwrap(); + let test_snapshot = json!({ + "snapshot": { + "meta": { + "node_fields": ["id", "name", "self_size", "edge_count"], + "node_types": ["number", "string", "number", "number"] + } + }, + "nodes": [123, 0, 1024, 0, 456, 1, 2048, 0], + "strings": ["TestObject", "AnotherObject"] + }); + write!(file, "{}", test_snapshot.to_string()).unwrap(); + + let (name, size) = parse_node_from_snapshot(file.path().to_str().unwrap(), 456).unwrap(); + assert_eq!(name, "AnotherObject"); + assert_eq!(size, 2048); + } } diff --git a/src/lib.rs b/src/lib.rs index a33ef9e..a019141 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -229,13 +229,13 @@ pub enum Commands { output: String, }, - /// Retrieve the dominator chain for a specific node ID from a local heap snapshot - #[command(name = "get-heapsnapshot-dominators")] - GetHeapSnapshotDominators { + /// Inspect a specific node ID details from a local heap snapshot + #[command(name = "inspect-heapsnapshot-node")] + InspectHeapSnapshotNode { /// Path to the .heapsnapshot file to analyze #[arg(long, short)] file_path: String, - /// Node ID to get the dominator chain for + /// Node ID to inspect #[arg(long, short)] node_id: u64, }, @@ -342,7 +342,7 @@ impl Cli { fn is_browser_level(&self) -> bool { matches!( self.command, - Commands::ListPages | Commands::NewPage { .. } | Commands::SwLogs { .. } + Commands::ListPages | Commands::NewPage { .. } | Commands::SwLogs { .. } | Commands::InspectHeapSnapshotNode { .. } ) } @@ -365,7 +365,7 @@ impl Cli { Commands::Snapshot { .. } => "snapshot", Commands::ReadPage { .. } => "read-page", Commands::TakeHeapSnapshot { .. } => "take-heapsnapshot", - Commands::GetHeapSnapshotDominators { .. } => "get-heapsnapshot-dominators", + Commands::InspectHeapSnapshotNode { .. } => "inspect-heapsnapshot-node", Commands::Emulate { .. } => "emulate", Commands::WaitFor { .. } => "wait-for", Commands::List3pTools => "list-3p-tools", @@ -442,8 +442,8 @@ fn build_request(cli: &Cli) -> DaemonRequest { "take-heapsnapshot", json!({ "output": output }), ), - Commands::GetHeapSnapshotDominators { file_path, node_id } => ( - "get-heapsnapshot-dominators", + Commands::InspectHeapSnapshotNode { file_path, node_id } => ( + "inspect-heapsnapshot-node", json!({ "file_path": file_path, "node_id": node_id }), ), Commands::Screenshot { @@ -795,6 +795,16 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { ) .await } + Commands::InspectHeapSnapshotNode { file_path, node_id } => { + commands::memory::inspect_heapsnapshot_node( + &mut client, + "", + file_path, + *node_id, + cli.output_format(), + ) + .await + } _ => unreachable!(), }; } @@ -939,10 +949,13 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { ) .await } - Commands::GetHeapSnapshotDominators { file_path, node_id } => { - commands::memory::get_heapsnapshot_dominators( + Commands::InspectHeapSnapshotNode { file_path, node_id } => { + commands::memory::inspect_heapsnapshot_node( + &mut client, + &session_id, file_path, *node_id, + cli.output_format(), ) .await } From beae5a5f9d1bb1f48e7e37f705591590fc039b95 Mon Sep 17 00:00:00 2001 From: Aero Date: Thu, 25 Jun 2026 23:57:16 +0800 Subject: [PATCH 04/20] fix(cli): implement blocking task wrapping for snapshot parsing, push back other cdp events, and clamp screenshot quality --- src/commands/memory.rs | 9 ++++++++- src/commands/screenshot.rs | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/commands/memory.rs b/src/commands/memory.rs index f777dc8..ebc940a 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -46,6 +46,8 @@ pub async fn take_heapsnapshot( if let Some(chunk) = event["params"]["chunk"].as_str() { file.write_all(chunk.as_bytes()).await?; } + } else if event.get("method").is_some() { + client.events.push_back(event); } } Ok::<(), anyhow::Error>(()) @@ -115,7 +117,12 @@ pub async fn inspect_heapsnapshot_node( node_id: u64, format: crate::format::OutputFormat, ) -> Result { - let (name, self_size) = parse_node_from_snapshot(file_path, node_id)?; + let file_path_owned = file_path.to_string(); + let (name, self_size) = tokio::task::spawn_blocking(move || { + parse_node_from_snapshot(&file_path_owned, node_id) + }) + .await + .map_err(|e| anyhow!("Failed to execute blocking snapshot parser: {e}"))??; if format.is_text() { let mut out = String::new(); diff --git a/src/commands/screenshot.rs b/src/commands/screenshot.rs index 8e0b70f..59a9577 100644 --- a/src/commands/screenshot.rs +++ b/src/commands/screenshot.rs @@ -23,7 +23,7 @@ pub async fn take_screenshot( if let Some(q) = quality { if format != "png" { - params["quality"] = json!(q); + params["quality"] = json!(q.min(100)); } } From 138176fad112a110ad1bc068ecd1ed8b66086968 Mon Sep 17 00:00:00 2001 From: Aero Date: Fri, 26 Jun 2026 00:09:39 +0800 Subject: [PATCH 05/20] fix(cli): optimize memory and parsing safety of heap snapshot lookups via strongly typed deserialization --- src/commands/memory.rs | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/commands/memory.rs b/src/commands/memory.rs index ebc940a..52fc99d 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -63,6 +63,23 @@ pub async fn take_heapsnapshot( ))) } +#[derive(serde::Deserialize)] +struct MetaDetails { + node_fields: Vec, +} + +#[derive(serde::Deserialize)] +struct SnapshotMeta { + meta: MetaDetails, +} + +#[derive(serde::Deserialize)] +struct HeapSnapshot { + snapshot: SnapshotMeta, + nodes: Vec, + strings: Vec, +} + /// Parse the JSON heap snapshot and locate details for the given node ID. /// Returns a tuple of (node_name, self_size). pub fn parse_node_from_snapshot( @@ -71,24 +88,25 @@ pub fn parse_node_from_snapshot( ) -> Result<(String, u64)> { let file = File::open(file_path)?; let reader = BufReader::new(file); - let val: serde_json::Value = serde_json::from_reader(reader)?; + let val: HeapSnapshot = serde_json::from_reader(reader)?; - let nodes = val["nodes"].as_array().ok_or_else(|| anyhow!("Invalid snapshot: nodes array missing"))?; - - let meta = &val["snapshot"]["meta"]; - let node_fields = meta["node_fields"].as_array().ok_or_else(|| anyhow!("Invalid snapshot: node_fields missing"))?; + let nodes = &val.nodes; + let node_fields = &val.snapshot.meta.node_fields; // Find fields offsets within the flat nodes array - let id_offset = node_fields.iter().position(|f| f.as_str() == Some("id")).ok_or_else(|| anyhow!("id field missing"))?; - let name_offset = node_fields.iter().position(|f| f.as_str() == Some("name")).ok_or_else(|| anyhow!("name field missing"))?; - let self_size_offset = node_fields.iter().position(|f| f.as_str() == Some("self_size")).ok_or_else(|| anyhow!("self_size field missing"))?; + let id_offset = node_fields.iter().position(|f| f == "id").ok_or_else(|| anyhow!("id field missing"))?; + let name_offset = node_fields.iter().position(|f| f == "name").ok_or_else(|| anyhow!("name field missing"))?; + let self_size_offset = node_fields.iter().position(|f| f == "self_size").ok_or_else(|| anyhow!("self_size field missing"))?; let node_size = node_fields.len(); + if node_size == 0 { + bail!("Invalid snapshot: node_fields is empty"); + } // Iterate over nodes using chunk sizes defined by the schema meta let mut target_index = None; let mut current_idx = 0; while current_idx + id_offset < nodes.len() { - let id = nodes.get(current_idx + id_offset).and_then(|v| v.as_u64()).unwrap_or(0); + let id = nodes[current_idx + id_offset]; if id == node_id { target_index = Some(current_idx); break; @@ -101,9 +119,9 @@ pub fn parse_node_from_snapshot( None => bail!("Node with ID {} not found", node_id), }; - let name_str_idx = nodes.get(target_node_index + name_offset).and_then(|v| v.as_u64()).unwrap_or(0) as usize; - let name = val["strings"].get(name_str_idx).and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); - let self_size = nodes.get(target_node_index + self_size_offset).and_then(|v| v.as_u64()).unwrap_or(0); + let name_str_idx = nodes[target_node_index + name_offset] as usize; + let name = val.strings.get(name_str_idx).cloned().unwrap_or_else(|| "unknown".to_string()); + let self_size = nodes[target_node_index + self_size_offset]; Ok((name, self_size)) } From 50c5a5817f78cfea208c20949b95a615b0b83f01 Mon Sep 17 00:00:00 2001 From: Aero Date: Fri, 26 Jun 2026 00:24:30 +0800 Subject: [PATCH 06/20] fix(cli): implement safe node fields boundary validation, bypass reportHeapSnapshotProgress pollution, and remove unreachable Commands match --- src/commands/memory.rs | 6 ++++++ src/lib.rs | 10 ---------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/commands/memory.rs b/src/commands/memory.rs index 52fc99d..62c6edc 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -46,6 +46,8 @@ pub async fn take_heapsnapshot( if let Some(chunk) = event["params"]["chunk"].as_str() { file.write_all(chunk.as_bytes()).await?; } + } else if method == "HeapProfiler.reportHeapSnapshotProgress" { + // Ignore progress events to avoid polluting the client events buffer } else if event.get("method").is_some() { client.events.push_back(event); } @@ -119,6 +121,10 @@ pub fn parse_node_from_snapshot( None => bail!("Node with ID {} not found", node_id), }; + if target_node_index + node_size > nodes.len() { + bail!("Invalid snapshot: node fields out of bounds"); + } + let name_str_idx = nodes[target_node_index + name_offset] as usize; let name = val.strings.get(name_str_idx).cloned().unwrap_or_else(|| "unknown".to_string()); let self_size = nodes[target_node_index + self_size_offset]; diff --git a/src/lib.rs b/src/lib.rs index a019141..c8c9aa4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -949,16 +949,6 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { ) .await } - Commands::InspectHeapSnapshotNode { file_path, node_id } => { - commands::memory::inspect_heapsnapshot_node( - &mut client, - &session_id, - file_path, - *node_id, - cli.output_format(), - ) - .await - } Commands::Evaluate { expression, dialog_action: _, From 8d0278b5afb31179959b5d01d2634d21efd5afc5 Mon Sep 17 00:00:00 2001 From: Aero Date: Fri, 26 Jun 2026 00:44:09 +0800 Subject: [PATCH 07/20] fix(cli): escape and sanitize special characters in node name text formatting --- src/commands/memory.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/commands/memory.rs b/src/commands/memory.rs index 62c6edc..b4b8c47 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -151,9 +151,14 @@ pub async fn inspect_heapsnapshot_node( if format.is_text() { let mut out = String::new(); out.push_str("nodeId,nodeName,selfSize,retainedSize\n"); + let escaped_name = if name.contains(',') || name.contains('"') || name.contains('\n') || name.contains('\r') { + format!("\"{}\"", name.replace('"', "\"\"")) + } else { + name.clone() + }; out.push_str(&format!( "{},{},{} B,unknown\n", - node_id, name, self_size + node_id, escaped_name, self_size )); Ok(CommandResult::output(out)) } else { From 8612ce6afc1b0cf9d9be100888d666389873f89f Mon Sep 17 00:00:00 2001 From: Aero Date: Fri, 26 Jun 2026 01:02:58 +0800 Subject: [PATCH 08/20] fix(cli): integrate format structured outputs, scrolling coordinate offsets in downscaled viewports, and clean code paths --- src/commands/executor.rs | 2 +- src/commands/memory.rs | 149 ++++++++++++++++++++++++++++--------- src/commands/screenshot.rs | 10 ++- src/lib.rs | 1 + 4 files changed, 121 insertions(+), 41 deletions(-) diff --git a/src/commands/executor.rs b/src/commands/executor.rs index 965666f..5e47267 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -464,7 +464,7 @@ async fn inner_execute( } "take-heapsnapshot" => match args.get("output").and_then(|v| v.as_str()) { Some(output) => { - commands::memory::take_heapsnapshot(client, session_id, output).await + commands::memory::take_heapsnapshot(client, session_id, output, req.format()).await } None => bail!("output required"), }, diff --git a/src/commands/memory.rs b/src/commands/memory.rs index b4b8c47..f8fd33c 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -11,11 +11,17 @@ pub async fn take_heapsnapshot( client: &mut CdpClient, session_id: &str, output: &str, + format: crate::format::OutputFormat, ) -> Result { - let mut file = tokio::fs::File::create(output).await?; + use anyhow::Context; + let mut file = tokio::fs::File::create(output) + .await + .with_context(|| format!("Failed to create heap snapshot output file: {}", output))?; // First, let's enable the HeapProfiler. - client.send_to_target(session_id, "HeapProfiler.enable", json!({})).await?; + client.send_to_target(session_id, "HeapProfiler.enable", json!({})) + .await + .context("Failed to enable HeapProfiler via CDP")?; let snapshot_result = async { // Send the takeHeapSnapshot command without blocking so we can process chunks as they stream in @@ -23,18 +29,23 @@ pub async fn take_heapsnapshot( Some(session_id), "HeapProfiler.takeHeapSnapshot", json!({ "reportProgress": true, "treatGlobalObjectsAsRoots": true, "captureNumericValue": true }), - ).await?; + ) + .await + .context("Failed to trigger non-blocking HeapProfiler.takeHeapSnapshot command")?; use tokio::io::AsyncWriteExt; loop { - let text = client.read_text().await?; - let event: serde_json::Value = serde_json::from_str(&text)?; + let text = client.read_text() + .await + .context("Failed to read WebSocket stream message during heap snapshot chunk collection")?; + let event: serde_json::Value = serde_json::from_str(&text) + .context("Failed to parse WebSocket text frame into JSON event")?; // Check if this is the completion response for our takeHeapSnapshot command if event.get("id").and_then(|v| v.as_u64()) == Some(msg_id) { if let Some(error) = event.get("error") { bail!( - "CDP error in HeapProfiler.takeHeapSnapshot: {}", + "CDP error in HeapProfiler.takeHeapSnapshot response: {}", serde_json::to_string_pretty(error)? ); } @@ -44,7 +55,9 @@ pub async fn take_heapsnapshot( let method = event["method"].as_str().unwrap_or(""); if method == "HeapProfiler.addHeapSnapshotChunk" { if let Some(chunk) = event["params"]["chunk"].as_str() { - file.write_all(chunk.as_bytes()).await?; + file.write_all(chunk.as_bytes()) + .await + .context("Failed to write snapshot chunk bytes to output file")?; } } else if method == "HeapProfiler.reportHeapSnapshotProgress" { // Ignore progress events to avoid polluting the client events buffer @@ -59,10 +72,19 @@ pub async fn take_heapsnapshot( let _ = client.send_to_target(session_id, "HeapProfiler.disable", json!({})).await; snapshot_result?; - Ok(CommandResult::output(format!( - "Heap snapshot successfully saved to {}", - output - ))) + if format.is_text() { + Ok(CommandResult::output(format!( + "Heap snapshot successfully saved to {}", + output + ))) + } else { + let details = json!({ + "success": true, + "output": output, + "message": format!("Heap snapshot successfully saved to {}", output) + }); + Ok(CommandResult::output(crate::format::format_structured(&details, format)?)) + } } #[derive(serde::Deserialize)] @@ -88,20 +110,26 @@ pub fn parse_node_from_snapshot( file_path: &str, node_id: u64, ) -> Result<(String, u64)> { - let file = File::open(file_path)?; + use anyhow::Context; + let file = File::open(file_path) + .with_context(|| format!("Failed to open heap snapshot file at: {}", file_path))?; let reader = BufReader::new(file); - let val: HeapSnapshot = serde_json::from_reader(reader)?; + let val: HeapSnapshot = serde_json::from_reader(reader) + .context("Failed to deserialize heap snapshot file. Ensure it is valid JSON.")?; let nodes = &val.nodes; let node_fields = &val.snapshot.meta.node_fields; // Find fields offsets within the flat nodes array - let id_offset = node_fields.iter().position(|f| f == "id").ok_or_else(|| anyhow!("id field missing"))?; - let name_offset = node_fields.iter().position(|f| f == "name").ok_or_else(|| anyhow!("name field missing"))?; - let self_size_offset = node_fields.iter().position(|f| f == "self_size").ok_or_else(|| anyhow!("self_size field missing"))?; + let id_offset = node_fields.iter().position(|f| f == "id") + .context("Invalid snapshot schema: 'id' node field meta is missing")?; + let name_offset = node_fields.iter().position(|f| f == "name") + .context("Invalid snapshot schema: 'name' node field meta is missing")?; + let self_size_offset = node_fields.iter().position(|f| f == "self_size") + .context("Invalid snapshot schema: 'self_size' node field meta is missing")?; let node_size = node_fields.len(); if node_size == 0 { - bail!("Invalid snapshot: node_fields is empty"); + bail!("Invalid snapshot: node_fields schema is empty"); } // Iterate over nodes using chunk sizes defined by the schema meta @@ -118,11 +146,11 @@ pub fn parse_node_from_snapshot( let target_node_index = match target_index { Some(idx) => idx, - None => bail!("Node with ID {} not found", node_id), + None => bail!("Node with ID {} not found in snapshot file", node_id), }; if target_node_index + node_size > nodes.len() { - bail!("Invalid snapshot: node fields out of bounds"); + bail!("Corrupted snapshot structure: target node index out of flat bounds"); } let name_str_idx = nodes[target_node_index + name_offset] as usize; @@ -132,35 +160,26 @@ pub fn parse_node_from_snapshot( Ok((name, self_size)) } -/// Look up details of a specific node from a local heap snapshot. -/// Adheres to the command-function contract, accepting client, session_id, and OutputFormat. -pub async fn inspect_heapsnapshot_node( - _client: &mut CdpClient, - _session_id: &str, - file_path: &str, +/// Format single node inspection details for display. +pub fn format_node_details( node_id: u64, + name: &str, + self_size: u64, format: crate::format::OutputFormat, -) -> Result { - let file_path_owned = file_path.to_string(); - let (name, self_size) = tokio::task::spawn_blocking(move || { - parse_node_from_snapshot(&file_path_owned, node_id) - }) - .await - .map_err(|e| anyhow!("Failed to execute blocking snapshot parser: {e}"))??; - +) -> Result { if format.is_text() { let mut out = String::new(); out.push_str("nodeId,nodeName,selfSize,retainedSize\n"); let escaped_name = if name.contains(',') || name.contains('"') || name.contains('\n') || name.contains('\r') { format!("\"{}\"", name.replace('"', "\"\"")) } else { - name.clone() + name.to_string() }; out.push_str(&format!( "{},{},{} B,unknown\n", node_id, escaped_name, self_size )); - Ok(CommandResult::output(out)) + Ok(out) } else { let details = json!({ "nodeId": node_id, @@ -168,10 +187,30 @@ pub async fn inspect_heapsnapshot_node( "selfSize": self_size, "retainedSize": null }); - Ok(CommandResult::output(crate::format::format_structured(&details, format)?)) + Ok(crate::format::format_structured(&details, format)?) } } +/// Look up details of a specific node from a local heap snapshot. +/// Adheres to the command-function contract, accepting client, session_id, and OutputFormat. +pub async fn inspect_heapsnapshot_node( + _client: &mut CdpClient, + _session_id: &str, + file_path: &str, + node_id: u64, + format: crate::format::OutputFormat, +) -> Result { + let file_path_owned = file_path.to_string(); + let (name, self_size) = tokio::task::spawn_blocking(move || { + parse_node_from_snapshot(&file_path_owned, node_id) + }) + .await + .map_err(|e| anyhow!("Failed to execute blocking snapshot parser: {e}"))??; + + let out = format_node_details(node_id, &name, self_size, format)?; + Ok(CommandResult::output(out)) +} + #[cfg(test)] mod tests { use super::*; @@ -197,4 +236,42 @@ mod tests { assert_eq!(name, "AnotherObject"); assert_eq!(size, 2048); } + + #[test] + fn test_format_node_details_csv_escaping() { + use crate::format::OutputFormat; + + // Regular name + let out_normal = format_node_details(123, "MyClass", 100, OutputFormat::Text).unwrap(); + assert_eq!(out_normal, "nodeId,nodeName,selfSize,retainedSize\n123,MyClass,100 B,unknown\n"); + + // Name with comma + let out_comma = format_node_details(123, "My,Class", 100, OutputFormat::Text).unwrap(); + assert_eq!(out_comma, "nodeId,nodeName,selfSize,retainedSize\n123,\"My,Class\",100 B,unknown\n"); + + // Name with quotes + let out_quotes = format_node_details(123, "My\"Class", 100, OutputFormat::Text).unwrap(); + assert_eq!(out_quotes, "nodeId,nodeName,selfSize,retainedSize\n123,\"My\"\"Class\",100 B,unknown\n"); + + // Name with newline + let out_nl = format_node_details(123, "My\nClass", 100, OutputFormat::Text).unwrap(); + assert_eq!(out_nl, "nodeId,nodeName,selfSize,retainedSize\n123,\"My\nClass\",100 B,unknown\n"); + } + + #[test] + fn test_format_node_details_structured() { + use crate::format::OutputFormat; + + // JSON format + let out_json = format_node_details(456, "ClassA", 200, OutputFormat::Json).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&out_json).unwrap(); + assert_eq!(parsed["nodeId"], 456); + assert_eq!(parsed["nodeName"], "ClassA"); + assert_eq!(parsed["selfSize"], 200); + + // TOON format + let out_toon = format_node_details(456, "ClassA", 200, OutputFormat::Toon).unwrap(); + assert!(out_toon.contains("nodeId")); + assert!(out_toon.contains("ClassA")); + } } diff --git a/src/commands/screenshot.rs b/src/commands/screenshot.rs index 59a9577..20e7572 100644 --- a/src/commands/screenshot.rs +++ b/src/commands/screenshot.rs @@ -27,9 +27,10 @@ pub async fn take_screenshot( } } - // Determine the viewport or document scroll dimensions let mut src_w = 1920.0; let mut src_h = 1080.0; + let mut scroll_x = 0.0; + let mut scroll_y = 0.0; let is_full_page = full_page; if full_page { @@ -51,13 +52,12 @@ pub async fn take_screenshot( } } } else { - // Query current viewport bounds if not capturing the full page let metrics = client .send_to_target( session_id, "Runtime.evaluate", json!({ - "expression": "JSON.stringify({width: window.innerWidth, height: window.innerHeight})", + "expression": "JSON.stringify({width: window.innerWidth, height: window.innerHeight, scrollX: window.scrollX || window.pageXOffset || 0, scrollY: window.scrollY || window.pageYOffset || 0})", "returnByValue": true, }), ) @@ -66,6 +66,8 @@ pub async fn take_screenshot( if let Ok(dims) = serde_json::from_str::(val) { src_w = dims["width"].as_f64().unwrap_or(1920.0); src_h = dims["height"].as_f64().unwrap_or(1080.0); + scroll_x = dims["scrollX"].as_f64().unwrap_or(0.0); + scroll_y = dims["scrollY"].as_f64().unwrap_or(0.0); } } } @@ -86,7 +88,7 @@ pub async fn take_screenshot( if is_full_page || clip_scale < 1.0 { params["clip"] = json!({ - "x": 0, "y": 0, + "x": scroll_x, "y": scroll_y, "width": src_w, "height": src_h, "scale": clip_scale, }); diff --git a/src/lib.rs b/src/lib.rs index c8c9aa4..c52fbd6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -946,6 +946,7 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { &mut client, &session_id, output, + cli.output_format(), ) .await } From adf6a788de8b41aad1f750394fc7466f4ccae8a7 Mon Sep 17 00:00:00 2001 From: Aero Date: Fri, 26 Jun 2026 13:30:59 +0800 Subject: [PATCH 09/20] fix: improve file I/O performance and format handling in memory and screenshot commands * buffer heap snapshot writes using `BufWriter` to reduce syscall overhead for large outputs * explicitly flush buffered snapshot data before completion to avoid blocking on drop * normalize screenshot format to lowercase to ensure compatibility with CDP and quality checks --- src/commands/memory.rs | 15 ++++++++++++--- src/commands/screenshot.rs | 3 +++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/commands/memory.rs b/src/commands/memory.rs index f8fd33c..2f61b57 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -14,9 +14,13 @@ pub async fn take_heapsnapshot( format: crate::format::OutputFormat, ) -> Result { use anyhow::Context; - let mut file = tokio::fs::File::create(output) - .await - .with_context(|| format!("Failed to create heap snapshot output file: {}", output))?; + // Heap snapshots can be tens or hundreds of MB; buffer the writes to avoid a + // syscall per streamed chunk. + let mut file = tokio::io::BufWriter::new( + tokio::fs::File::create(output) + .await + .with_context(|| format!("Failed to create heap snapshot output file: {}", output))?, + ); // First, let's enable the HeapProfiler. client.send_to_target(session_id, "HeapProfiler.enable", json!({})) @@ -65,6 +69,11 @@ pub async fn take_heapsnapshot( client.events.push_back(event); } } + // Flush any buffered snapshot bytes before the writer is dropped; + // BufWriter::drop performs a blocking flush, which we avoid in async code. + file.flush() + .await + .context("Failed to flush buffered heap snapshot bytes to output file")?; Ok::<(), anyhow::Error>(()) } .await; diff --git a/src/commands/screenshot.rs b/src/commands/screenshot.rs index 20e7572..337341a 100644 --- a/src/commands/screenshot.rs +++ b/src/commands/screenshot.rs @@ -16,6 +16,9 @@ pub async fn take_screenshot( max_width: Option, max_height: Option, ) -> Result { + // Normalize so case-insensitive input (e.g. "PNG") is handled correctly: + // CDP expects lowercase format values, and the quality check below relies on it. + let format = format.to_ascii_lowercase(); let mut params = json!({ "format": format, "optimizeForSpeed": true, From aa602283506ae3fbe069129b8f59a1cd4212300d Mon Sep 17 00:00:00 2001 From: Aero Date: Fri, 26 Jun 2026 13:38:12 +0800 Subject: [PATCH 10/20] refactor: encapsulate screenshot parameters into `ScreenshotOptions` * replace multiple screenshot function arguments with `ScreenshotOptions` struct * update executor and direct run paths to construct and pass options object * simplify function signature and improve maintainability * fix file write call to use borrowed path reference --- src/commands/executor.rs | 23 +++++++++++++++-------- src/commands/screenshot.rs | 26 +++++++++++++++++++------- src/lib.rs | 14 ++++++++------ 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/commands/executor.rs b/src/commands/executor.rs index 5e47267..fca7322 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -379,14 +379,21 @@ async fn inner_execute( commands::screenshot::take_screenshot( client, session_id, - args.get("output").and_then(|v| v.as_str()), - args.get("format").and_then(|v| v.as_str()).unwrap_or("png"), - args.get("full_page") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - args.get("quality").and_then(|v| v.as_u64()), - args.get("max_width").and_then(|v| v.as_f64()), - args.get("max_height").and_then(|v| v.as_f64()), + commands::screenshot::ScreenshotOptions { + output: args.get("output").and_then(|v| v.as_str()).map(String::from), + format: args + .get("format") + .and_then(|v| v.as_str()) + .unwrap_or("png") + .to_string(), + full_page: args + .get("full_page") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + quality: args.get("quality").and_then(|v| v.as_u64()), + max_width: args.get("max_width").and_then(|v| v.as_f64()), + max_height: args.get("max_height").and_then(|v| v.as_f64()), + }, ) .await } diff --git a/src/commands/screenshot.rs b/src/commands/screenshot.rs index 337341a..94f820a 100644 --- a/src/commands/screenshot.rs +++ b/src/commands/screenshot.rs @@ -6,16 +6,28 @@ use crate::cdp::CdpClient; use crate::result::CommandResult; /// Capture a screenshot of the current page. +pub struct ScreenshotOptions { + pub output: Option, + pub format: String, + pub full_page: bool, + pub quality: Option, + pub max_width: Option, + pub max_height: Option, +} + pub async fn take_screenshot( client: &mut CdpClient, session_id: &str, - output: Option<&str>, - format: &str, - full_page: bool, - quality: Option, - max_width: Option, - max_height: Option, + opts: ScreenshotOptions, ) -> Result { + let ScreenshotOptions { + output, + format, + full_page, + quality, + max_width, + max_height, + } = opts; // Normalize so case-insensitive input (e.g. "PNG") is handled correctly: // CDP expects lowercase format values, and the quality check below relies on it. let format = format.to_ascii_lowercase(); @@ -109,7 +121,7 @@ pub async fn take_screenshot( match output { Some(path) => { - tokio::fs::write(path, &bytes).await?; + tokio::fs::write(&path, &bytes).await?; Ok(CommandResult::output(format!( "Screenshot saved to {path} ({} bytes)", bytes.len() diff --git a/src/lib.rs b/src/lib.rs index c52fbd6..34dd51c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -932,12 +932,14 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { commands::screenshot::take_screenshot( &mut client, &session_id, - output.as_deref(), - format, - *full_page, - *quality, - *max_width, - *max_height, + commands::screenshot::ScreenshotOptions { + output: output.clone(), + format: format.clone(), + full_page: *full_page, + quality: *quality, + max_width: *max_width, + max_height: *max_height, + }, ) .await } From 7e981174cf54c1f55bb9022ca6e895e7426d4bc5 Mon Sep 17 00:00:00 2001 From: Aero Date: Fri, 26 Jun 2026 13:54:50 +0800 Subject: [PATCH 11/20] refactor: improve event handling and optimize screenshot scaling logic * expose `CdpClient::push_event` for controlled event routing and buffering * update heap snapshot handling to use `push_event` for capped and categorized event storage * simplify screenshot metric retrieval by fetching dimensions only when needed * remove scroll offset usage and standardize clip origin to (0,0) for correctness * extract `clip_scale_factor` helper for clearer downscaling logic * add comprehensive unit tests for clip scaling edge cases * improve readability and maintainability of screenshot capture flow --- src/cdp.rs | 2 +- src/commands/memory.rs | 5 +- src/commands/screenshot.rs | 163 +++++++++++++++++++++++++------------ 3 files changed, 115 insertions(+), 55 deletions(-) diff --git a/src/cdp.rs b/src/cdp.rs index 7774dd5..0efaf2f 100644 --- a/src/cdp.rs +++ b/src/cdp.rs @@ -406,7 +406,7 @@ impl CdpClient { std::mem::take(&mut self.console_events).into() } - fn push_event(&mut self, event: Value) { + pub(crate) fn push_event(&mut self, event: Value) { // Only route events into the persistent buffers when the event's // sessionId matches the persistent page session (flatten-mode events // are tagged with sessionId). Events from ad-hoc sessions (sw-logs diff --git a/src/commands/memory.rs b/src/commands/memory.rs index 2f61b57..68b8e51 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -66,7 +66,10 @@ pub async fn take_heapsnapshot( } else if method == "HeapProfiler.reportHeapSnapshotProgress" { // Ignore progress events to avoid polluting the client events buffer } else if event.get("method").is_some() { - client.events.push_back(event); + // Route through push_event so Network/Runtime events land in + // network_events/console_events (capped) instead of the generic + // unbounded buffer, and other events get capped too. + client.push_event(event); } } // Flush any buffered snapshot bytes before the writer is dropped; diff --git a/src/commands/screenshot.rs b/src/commands/screenshot.rs index 94f820a..d8b5b48 100644 --- a/src/commands/screenshot.rs +++ b/src/commands/screenshot.rs @@ -42,68 +42,59 @@ pub async fn take_screenshot( } } + // src_w/src_h are only needed when a clip will be emitted + // (full-page capture, or downscaling via max_width/max_height). let mut src_w = 1920.0; let mut src_h = 1080.0; - let mut scroll_x = 0.0; - let mut scroll_y = 0.0; - let is_full_page = full_page; - - if full_page { - params["captureBeyondViewport"] = json!(true); - let metrics = client - .send_to_target( - session_id, - "Runtime.evaluate", - json!({ - "expression": "JSON.stringify({width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight})", - "returnByValue": true, - }), - ) - .await?; - if let Some(val) = metrics["result"]["value"].as_str() { - if let Ok(dims) = serde_json::from_str::(val) { - src_w = dims["width"].as_f64().unwrap_or(1920.0); - src_h = dims["height"].as_f64().unwrap_or(1080.0); + let needs_metrics = full_page || max_width.is_some() || max_height.is_some(); + + if needs_metrics { + if full_page { + params["captureBeyondViewport"] = json!(true); + let metrics = client + .send_to_target( + session_id, + "Runtime.evaluate", + json!({ + "expression": "JSON.stringify({width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight})", + "returnByValue": true, + }), + ) + .await?; + if let Some(val) = metrics["result"]["value"].as_str() { + if let Ok(dims) = serde_json::from_str::(val) { + src_w = dims["width"].as_f64().unwrap_or(1920.0); + src_h = dims["height"].as_f64().unwrap_or(1080.0); + } } - } - } else { - let metrics = client - .send_to_target( - session_id, - "Runtime.evaluate", - json!({ - "expression": "JSON.stringify({width: window.innerWidth, height: window.innerHeight, scrollX: window.scrollX || window.pageXOffset || 0, scrollY: window.scrollY || window.pageYOffset || 0})", - "returnByValue": true, - }), - ) - .await?; - if let Some(val) = metrics["result"]["value"].as_str() { - if let Ok(dims) = serde_json::from_str::(val) { - src_w = dims["width"].as_f64().unwrap_or(1920.0); - src_h = dims["height"].as_f64().unwrap_or(1080.0); - scroll_x = dims["scrollX"].as_f64().unwrap_or(0.0); - scroll_y = dims["scrollY"].as_f64().unwrap_or(0.0); + } else { + let metrics = client + .send_to_target( + session_id, + "Runtime.evaluate", + json!({ + "expression": "JSON.stringify({width: window.innerWidth, height: window.innerHeight})", + "returnByValue": true, + }), + ) + .await?; + if let Some(val) = metrics["result"]["value"].as_str() { + if let Ok(dims) = serde_json::from_str::(val) { + src_w = dims["width"].as_f64().unwrap_or(1920.0); + src_h = dims["height"].as_f64().unwrap_or(1080.0); + } } } } - // Downscaling logic (calculate custom clip with scale factor) - let mut clip_scale = 1.0; - if max_width.is_some() || max_height.is_some() { - let width_scale = match max_width { - Some(max_w) if max_w > 0.0 && src_w > 0.0 => (max_w / src_w).min(1.0), - _ => 1.0, - }; - let height_scale = match max_height { - Some(max_h) if max_h > 0.0 && src_h > 0.0 => (max_h / src_h).min(1.0), - _ => 1.0, - }; - clip_scale = width_scale.min(height_scale); - } + let clip_scale = clip_scale_factor(src_w, src_h, max_width, max_height); - if is_full_page || clip_scale < 1.0 { + // Clip coordinates are relative to the captured region's top-left + // (the document origin for full-page, the viewport origin otherwise), + // so x/y are always 0.0 — document scroll offsets must NOT be used here. + if full_page || clip_scale < 1.0 { params["clip"] = json!({ - "x": scroll_x, "y": scroll_y, + "x": 0.0, "y": 0.0, "width": src_w, "height": src_h, "scale": clip_scale, }); @@ -130,3 +121,69 @@ pub async fn take_screenshot( None => Ok(CommandResult::output(data_b64.to_string())), } } + +/// Compute the downscale factor for a screenshot clip. +/// +/// Returns the smaller of the width and height scale ratios, clamped to <= 1.0 +/// (never upscales). A `None` dimension, or a non-positive max/src value, yields +/// 1.0 for that axis (no scaling). Returns 1.0 when neither dimension is set. +fn clip_scale_factor(src_w: f64, src_h: f64, max_width: Option, max_height: Option) -> f64 { + let width_scale = match max_width { + Some(max_w) if max_w > 0.0 && src_w > 0.0 => (max_w / src_w).min(1.0), + _ => 1.0, + }; + let height_scale = match max_height { + Some(max_h) if max_h > 0.0 && src_h > 0.0 => (max_h / src_h).min(1.0), + _ => 1.0, + }; + width_scale.min(height_scale) +} + +#[cfg(test)] +mod tests { + use super::clip_scale_factor; + + #[test] + fn no_max_dimensions_returns_one() { + assert_eq!(clip_scale_factor(1920.0, 1080.0, None, None), 1.0); + } + + #[test] + fn zero_max_is_treated_as_no_scaling() { + assert_eq!(clip_scale_factor(1920.0, 1080.0, Some(0.0), Some(0.0)), 1.0); + } + + #[test] + fn negative_max_is_treated_as_no_scaling() { + assert_eq!(clip_scale_factor(1920.0, 1080.0, Some(-100.0), Some(-50.0)), 1.0); + } + + #[test] + fn zero_source_is_treated_as_no_scaling() { + assert_eq!(clip_scale_factor(0.0, 0.0, Some(100.0), Some(100.0)), 1.0); + } + + #[test] + fn one_sided_width_downscales_only_width() { + // src 1920x1080, max_width 960 → width_scale 0.5, height_scale 1.0 → 0.5 + assert_eq!(clip_scale_factor(1920.0, 1080.0, Some(960.0), None), 0.5); + } + + #[test] + fn one_sided_height_downscales_only_height() { + // src 1920x1080, max_height 540 → height_scale 0.5, width_scale 1.0 → 0.5 + assert_eq!(clip_scale_factor(1920.0, 1080.0, None, Some(540.0)), 0.5); + } + + #[test] + fn both_dimensions_uses_the_smaller_ratio() { + // src 2000x1000, max 1000x250 → width_scale 0.5, height_scale 0.25 → 0.25 + assert_eq!(clip_scale_factor(2000.0, 1000.0, Some(1000.0), Some(250.0)), 0.25); + } + + #[test] + fn never_upscales_when_max_exceeds_source() { + // src 800x600, max 1600x1200 → both ratios > 1.0, clamped to 1.0 + assert_eq!(clip_scale_factor(800.0, 600.0, Some(1600.0), Some(1200.0)), 1.0); + } +} From 55754650efc75171211c1b802f879ce80e7c2683 Mon Sep 17 00:00:00 2001 From: Aero Date: Fri, 26 Jun 2026 15:25:14 +0800 Subject: [PATCH 12/20] fix: reduce heap snapshot noise and improve screenshot metric fallback * disable `reportHeapSnapshotProgress` to avoid unnecessary event handling * remove redundant progress-event branch in memory command * improve screenshot dimension fallback using optional chaining and viewport defaults --- src/commands/memory.rs | 4 +--- src/commands/screenshot.rs | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/commands/memory.rs b/src/commands/memory.rs index 68b8e51..62bc3e8 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -32,7 +32,7 @@ pub async fn take_heapsnapshot( let msg_id = client.send_raw_no_wait( Some(session_id), "HeapProfiler.takeHeapSnapshot", - json!({ "reportProgress": true, "treatGlobalObjectsAsRoots": true, "captureNumericValue": true }), + json!({ "reportProgress": false, "treatGlobalObjectsAsRoots": true, "captureNumericValue": true }), ) .await .context("Failed to trigger non-blocking HeapProfiler.takeHeapSnapshot command")?; @@ -63,8 +63,6 @@ pub async fn take_heapsnapshot( .await .context("Failed to write snapshot chunk bytes to output file")?; } - } else if method == "HeapProfiler.reportHeapSnapshotProgress" { - // Ignore progress events to avoid polluting the client events buffer } else if event.get("method").is_some() { // Route through push_event so Network/Runtime events land in // network_events/console_events (capped) instead of the generic diff --git a/src/commands/screenshot.rs b/src/commands/screenshot.rs index d8b5b48..8d10b4e 100644 --- a/src/commands/screenshot.rs +++ b/src/commands/screenshot.rs @@ -56,7 +56,7 @@ pub async fn take_screenshot( session_id, "Runtime.evaluate", json!({ - "expression": "JSON.stringify({width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight})", + "expression": "JSON.stringify({width: (document.documentElement?.scrollWidth ?? window.innerWidth), height: (document.documentElement?.scrollHeight ?? window.innerHeight)})", "returnByValue": true, }), ) From 8c25abaecaa6d9218cb0454f7b4ccbec56d1b733 Mon Sep 17 00:00:00 2001 From: Aero Date: Fri, 26 Jun 2026 16:54:57 +0800 Subject: [PATCH 13/20] feat: improve robustness and reliability of memory snapshot and screenshot commands * write heap snapshots to temporary file and atomically rename on success to avoid corrupt outputs * clean up partial temp files on failure * add buffered writes with explicit flush and improved error context * validate snapshot string indices and return explicit corruption errors * switch screenshot metrics to `Page.getLayoutMetrics` for better accuracy and non-DOM support * simplify dimension retrieval logic and remove JS evaluation dependency * add detailed error context for CDP calls and file writes --- src/commands/memory.rs | 24 +++++++++++++--- src/commands/screenshot.rs | 58 ++++++++++++++++---------------------- 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/commands/memory.rs b/src/commands/memory.rs index 62bc3e8..e3ffd93 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -14,12 +14,17 @@ pub async fn take_heapsnapshot( format: crate::format::OutputFormat, ) -> Result { use anyhow::Context; + // Write to a temp file in the same directory so a failed/partial stream + // never leaves a corrupt file at the final output path. The temp file is + // renamed to `output` only after the snapshot completes successfully. + let output_path = std::path::Path::new(output); + let temp_path = output_path.with_extension("heapsnapshot.tmp"); // Heap snapshots can be tens or hundreds of MB; buffer the writes to avoid a // syscall per streamed chunk. let mut file = tokio::io::BufWriter::new( - tokio::fs::File::create(output) + tokio::fs::File::create(&temp_path) .await - .with_context(|| format!("Failed to create heap snapshot output file: {}", output))?, + .with_context(|| format!("Failed to create heap snapshot temp file: {}", temp_path.display()))?, ); // First, let's enable the HeapProfiler. @@ -80,7 +85,17 @@ pub async fn take_heapsnapshot( .await; let _ = client.send_to_target(session_id, "HeapProfiler.disable", json!({})).await; - snapshot_result?; + + if let Err(e) = snapshot_result { + // Clean up the partial temp file so a failed run leaves no artifacts. + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(e); + } + + // Atomically move the completed temp file to the final output path. + tokio::fs::rename(&temp_path, output_path) + .await + .with_context(|| format!("Failed to rename temp file to final output: {}", output))?; if format.is_text() { Ok(CommandResult::output(format!( @@ -164,7 +179,8 @@ pub fn parse_node_from_snapshot( } let name_str_idx = nodes[target_node_index + name_offset] as usize; - let name = val.strings.get(name_str_idx).cloned().unwrap_or_else(|| "unknown".to_string()); + let name = val.strings.get(name_str_idx).cloned() + .ok_or_else(|| anyhow!("Corrupt snapshot: string index {} out of bounds (strings len {})", name_str_idx, val.strings.len()))?; let self_size = nodes[target_node_index + self_size_offset]; Ok((name, self_size)) diff --git a/src/commands/screenshot.rs b/src/commands/screenshot.rs index 8d10b4e..f783154 100644 --- a/src/commands/screenshot.rs +++ b/src/commands/screenshot.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use base64::Engine; use serde_json::json; @@ -51,38 +51,27 @@ pub async fn take_screenshot( if needs_metrics { if full_page { params["captureBeyondViewport"] = json!(true); - let metrics = client - .send_to_target( - session_id, - "Runtime.evaluate", - json!({ - "expression": "JSON.stringify({width: (document.documentElement?.scrollWidth ?? window.innerWidth), height: (document.documentElement?.scrollHeight ?? window.innerHeight)})", - "returnByValue": true, - }), - ) - .await?; - if let Some(val) = metrics["result"]["value"].as_str() { - if let Ok(dims) = serde_json::from_str::(val) { - src_w = dims["width"].as_f64().unwrap_or(1920.0); - src_h = dims["height"].as_f64().unwrap_or(1080.0); - } + } + + // Use Page.getLayoutMetrics instead of Runtime.evaluate: it queries the + // renderer's layout system directly, works on non-HTML pages (PDF viewers, + // chrome://), and avoids a JS execution round-trip. + let metrics = client + .send_to_target(session_id, "Page.getLayoutMetrics", json!({})) + .await + .context("Failed to query page layout metrics")?; + + if full_page { + // contentSize is the full scrollable content area. + if let Some(size) = metrics.get("contentSize") { + src_w = size["width"].as_f64().unwrap_or(1920.0); + src_h = size["height"].as_f64().unwrap_or(1080.0); } } else { - let metrics = client - .send_to_target( - session_id, - "Runtime.evaluate", - json!({ - "expression": "JSON.stringify({width: window.innerWidth, height: window.innerHeight})", - "returnByValue": true, - }), - ) - .await?; - if let Some(val) = metrics["result"]["value"].as_str() { - if let Ok(dims) = serde_json::from_str::(val) { - src_w = dims["width"].as_f64().unwrap_or(1920.0); - src_h = dims["height"].as_f64().unwrap_or(1080.0); - } + // layoutViewport.clientWidth/Height is the visible viewport. + if let Some(viewport) = metrics.get("layoutViewport") { + src_w = viewport["clientWidth"].as_f64().unwrap_or(1920.0); + src_h = viewport["clientHeight"].as_f64().unwrap_or(1080.0); } } } @@ -102,7 +91,8 @@ pub async fn take_screenshot( let result = client .send_to_target(session_id, "Page.captureScreenshot", params) - .await?; + .await + .context("Failed to capture screenshot via CDP")?; let data_b64 = result["data"] .as_str() @@ -112,7 +102,9 @@ pub async fn take_screenshot( match output { Some(path) => { - tokio::fs::write(&path, &bytes).await?; + tokio::fs::write(&path, &bytes) + .await + .with_context(|| format!("Failed to write screenshot to {}", path))?; Ok(CommandResult::output(format!( "Screenshot saved to {path} ({} bytes)", bytes.len() From 9a897ab460adf8893e515c0f67c902f5d98e9dd4 Mon Sep 17 00:00:00 2001 From: Aero Date: Fri, 26 Jun 2026 17:26:56 +0800 Subject: [PATCH 14/20] feat: improve heap snapshot safety, offline inspection, and CLI path handling * use unique PID-based temp files for heap snapshots to avoid collisions and ensure atomic rename * clean up temp files on failure and prevent corrupt output artifacts * extract `find_node_in_snapshot` for pure schema validation and unit testing * add overflow-safe index handling and improved corruption errors * introduce offline `inspect_heapsnapshot_node` (no Chrome required) with CLI early intercept * remove daemon dependency for offline inspection command * absolutize file paths before sending to daemon to fix CWD inconsistencies --- src/commands/memory.rs | 82 ++++++++++++++++++++++++++++++++++++++++-- src/lib.rs | 66 +++++++++++++++++++++++----------- 2 files changed, 124 insertions(+), 24 deletions(-) diff --git a/src/commands/memory.rs b/src/commands/memory.rs index e3ffd93..dcfe536 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -18,7 +18,13 @@ pub async fn take_heapsnapshot( // never leaves a corrupt file at the final output path. The temp file is // renamed to `output` only after the snapshot completes successfully. let output_path = std::path::Path::new(output); - let temp_path = output_path.with_extension("heapsnapshot.tmp"); + // Unique temp file (PID-suffixed) in the same directory so concurrent runs + // can't collide, and rename is atomic (same filesystem). + let temp_path = output_path.with_file_name(format!( + ".{}.{}.tmp", + output_path.file_name().unwrap_or_default().to_string_lossy(), + std::process::id(), + )); // Heap snapshots can be tens or hundreds of MB; buffer the writes to avoid a // syscall per streamed chunk. let mut file = tokio::io::BufWriter::new( @@ -142,9 +148,16 @@ pub fn parse_node_from_snapshot( let val: HeapSnapshot = serde_json::from_reader(reader) .context("Failed to deserialize heap snapshot file. Ensure it is valid JSON.")?; + find_node_in_snapshot(&val, node_id) +} + +/// Pure schema-validation + node-lookup logic, separated from I/O so it can be +/// unit-tested without writing a temp file. +fn find_node_in_snapshot(val: &HeapSnapshot, node_id: u64) -> Result<(String, u64)> { + use anyhow::Context; let nodes = &val.nodes; let node_fields = &val.snapshot.meta.node_fields; - + // Find fields offsets within the flat nodes array let id_offset = node_fields.iter().position(|f| f == "id") .context("Invalid snapshot schema: 'id' node field meta is missing")?; @@ -178,7 +191,9 @@ pub fn parse_node_from_snapshot( bail!("Corrupted snapshot structure: target node index out of flat bounds"); } - let name_str_idx = nodes[target_node_index + name_offset] as usize; + let name_str_idx = usize::try_from(nodes[target_node_index + name_offset]) + .ok() + .context("Corrupt snapshot: string index overflow on 32-bit architecture")?; let name = val.strings.get(name_str_idx).cloned() .ok_or_else(|| anyhow!("Corrupt snapshot: string index {} out of bounds (strings len {})", name_str_idx, val.strings.len()))?; let self_size = nodes[target_node_index + self_size_offset]; @@ -225,6 +240,17 @@ pub async fn inspect_heapsnapshot_node( file_path: &str, node_id: u64, format: crate::format::OutputFormat, +) -> Result { + inspect_heapsnapshot_node_offline(file_path, node_id, format).await +} + +/// Offline variant that doesn't require a Chrome connection. Used by the CLI's +/// early-intercept path so `inspect-heapsnapshot-node` works without a running +/// browser or daemon. +pub async fn inspect_heapsnapshot_node_offline( + file_path: &str, + node_id: u64, + format: crate::format::OutputFormat, ) -> Result { let file_path_owned = file_path.to_string(); let (name, self_size) = tokio::task::spawn_blocking(move || { @@ -263,6 +289,56 @@ mod tests { assert_eq!(size, 2048); } + #[test] + fn test_find_node_in_snapshot_directly() { + // Exercise the pure helper without going through file I/O. + let snapshot = HeapSnapshot { + snapshot: SnapshotMeta { + meta: MetaDetails { + node_fields: vec!["id".into(), "name".into(), "self_size".into()], + }, + }, + nodes: vec![10, 0, 100, 20, 1, 200], + strings: vec!["Alpha".into(), "Beta".into()], + }; + + let (name, size) = find_node_in_snapshot(&snapshot, 20).unwrap(); + assert_eq!(name, "Beta"); + assert_eq!(size, 200); + } + + #[test] + fn test_find_node_not_found() { + let snapshot = HeapSnapshot { + snapshot: SnapshotMeta { + meta: MetaDetails { + node_fields: vec!["id".into(), "name".into(), "self_size".into()], + }, + }, + nodes: vec![10, 0, 100], + strings: vec!["Alpha".into()], + }; + + assert!(find_node_in_snapshot(&snapshot, 999).is_err()); + } + + #[test] + fn test_find_node_corrupt_string_index() { + // string index 5 is out of bounds (only 1 string exists) + let snapshot = HeapSnapshot { + snapshot: SnapshotMeta { + meta: MetaDetails { + node_fields: vec!["id".into(), "name".into(), "self_size".into()], + }, + }, + nodes: vec![10, 5, 100], + strings: vec!["Alpha".into()], + }; + + let err = find_node_in_snapshot(&snapshot, 10).unwrap_err(); + assert!(err.to_string().contains("out of bounds")); + } + #[test] fn test_format_node_details_csv_escaping() { use crate::format::OutputFormat; diff --git a/src/lib.rs b/src/lib.rs index 34dd51c..8f89daf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -342,7 +342,7 @@ impl Cli { fn is_browser_level(&self) -> bool { matches!( self.command, - Commands::ListPages | Commands::NewPage { .. } | Commands::SwLogs { .. } | Commands::InspectHeapSnapshotNode { .. } + Commands::ListPages | Commands::NewPage { .. } | Commands::SwLogs { .. } ) } @@ -379,7 +379,29 @@ impl Cli { } /// Build a DaemonRequest from parsed CLI args. +/// Resolve a relative path to an absolute one using the CLI process's CWD. +/// The daemon retains its own startup CWD, so relative paths sent to it would +/// resolve against the wrong directory. +fn absolutize_path(path: &str) -> String { + let p = std::path::Path::new(path); + if p.is_absolute() { + path.to_string() + } else { + std::env::current_dir() + .unwrap_or_default() + .join(p) + .to_string_lossy() + .to_string() + } +} + fn build_request(cli: &Cli) -> DaemonRequest { + // Resolve relative file paths to absolute so the daemon (which retains its + // own startup CWD) resolves them correctly. + let absolutize = |p: &Option| -> Option { + p.as_ref().map(|s| absolutize_path(s)) + }; + let (command, args) = match &cli.command { Commands::ListPages => ("list-pages", json!({})), Commands::Navigate { @@ -409,7 +431,7 @@ fn build_request(cli: &Cli) -> DaemonRequest { "geolocation": geolocation, "accuracy": accuracy, "clear_all": clear_all, - "output": output + "output": absolutize(output) }), ), Commands::NewPage { @@ -440,11 +462,7 @@ fn build_request(cli: &Cli) -> DaemonRequest { } Commands::TakeHeapSnapshot { output } => ( "take-heapsnapshot", - json!({ "output": output }), - ), - Commands::InspectHeapSnapshotNode { file_path, node_id } => ( - "inspect-heapsnapshot-node", - json!({ "file_path": file_path, "node_id": node_id }), + json!({ "output": absolutize_path(output) }), ), Commands::Screenshot { output, @@ -456,7 +474,7 @@ fn build_request(cli: &Cli) -> DaemonRequest { } => ( "screenshot", json!({ - "output": output, + "output": absolutize(output), "format": format, "full_page": full_page, "quality": quality, @@ -471,7 +489,7 @@ fn build_request(cli: &Cli) -> DaemonRequest { track_navigation, } => ( "evaluate", - json!({"expression": expression, "dialog_action": dialog_action, "output": output, "track_navigation": track_navigation}), + json!({"expression": expression, "dialog_action": dialog_action, "output": absolutize(output), "track_navigation": track_navigation}), ), Commands::Click { selector } => ("click", json!({"selector": selector})), Commands::ClickAt { x, y } => ("click-at", json!({"x": x, "y": y})), @@ -483,8 +501,8 @@ fn build_request(cli: &Cli) -> DaemonRequest { } Commands::PressKey { key } => ("press-key", json!({"key": key})), Commands::Hover { selector } => ("hover", json!({"selector": selector})), - Commands::Snapshot { output } => ("snapshot", json!({"output": output})), - Commands::ReadPage { output } => ("read-page", json!({"output": output})), + Commands::Snapshot { output } => ("snapshot", json!({"output": absolutize(output)})), + Commands::ReadPage { output } => ("read-page", json!({"output": absolutize(output)})), Commands::Emulate { viewport, device_scale_factor, @@ -520,6 +538,9 @@ fn build_request(cli: &Cli) -> DaemonRequest { json!({"duration": duration, "extension_id": extension_id}), ), Commands::KillDaemon => unreachable!("KillDaemon is handled before build_request"), + Commands::InspectHeapSnapshotNode { .. } => { + unreachable!("InspectHeapSnapshotNode is handled before build_request") + } }; DaemonRequest { @@ -686,6 +707,19 @@ pub async fn run() -> Result<()> { return Ok(()); } + // Handle inspect-heapsnapshot-node without connecting to Chrome — it's a + // purely offline operation that parses a local .heapsnapshot file. + if let Commands::InspectHeapSnapshotNode { file_path, node_id } = &cli.command { + let result = commands::memory::inspect_heapsnapshot_node_offline( + file_path, + *node_id, + cli.output_format(), + ) + .await?; + print_result(&result); + return Ok(()); + } + let ws_url = browser::resolve_ws_url( cli.ws_endpoint.as_deref(), cli.user_data_dir.as_deref(), @@ -795,16 +829,6 @@ async fn run_direct(cli: &Cli, ws_url: &str) -> Result { ) .await } - Commands::InspectHeapSnapshotNode { file_path, node_id } => { - commands::memory::inspect_heapsnapshot_node( - &mut client, - "", - file_path, - *node_id, - cli.output_format(), - ) - .await - } _ => unreachable!(), }; } From 50885a90ff8d065e3e2ee3ef220c7d29be146941 Mon Sep 17 00:00:00 2001 From: Aero Date: Fri, 26 Jun 2026 20:26:54 +0800 Subject: [PATCH 15/20] chore: update README --- AGENTS.md | 13 +++++++++++++ README.md | 12 +++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 079fb1a..9d1e61b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ src/ ├── screenshot.rs ├── snapshot.rs ├── read_page.rs # read-page (Readability + HTML→Markdown) + ├── memory.rs # take-heapsnapshot (CDP streaming) + inspect-heapsnapshot-node (offline) ├── evaluate.rs ├── input.rs # click/fill/type/press/hover ├── emulation.rs # emulate (viewport/geolocation/blocklist) @@ -70,6 +71,18 @@ continuously collects `Network.*` and `Runtime.*` events. `console` and All commands default to human-readable text. `--json` and `--toon` (compact, LLM-friendly) produce structured output. Mutually exclusive. +### Offline Commands + +`inspect-heapsnapshot-node` and `kill-daemon` are intercepted early in `run()` +before any Chrome connection or daemon spawn. `inspect-heapsnapshot-node` parses +a local `.heapsnapshot` file purely offline. + +### Path Resolution + +The daemon retains its startup CWD, so the CLI resolves all relative file-path +arguments (`--output`, `--file-path`) to absolute paths in `build_request` +before sending them to the daemon. + ## Build & Test ```bash diff --git a/README.md b/README.md index ec00a7c..9eb6c72 100644 --- a/README.md +++ b/README.md @@ -130,10 +130,13 @@ You can also use `--page ` for quick one-offs, or pass the raw hex target |---------|-------------| | `screenshot --output ` | Save screenshot to file | | `screenshot --full-page` | Capture full scrollable page | +| `screenshot --max-width --max-height ` | Downscale screenshot to fit within dimensions | | `read-page` | Read page content as clean markdown (extracts main article) | | `read-page --output ` | Save markdown to file | | `evaluate [--dialog-action ]` | Run JavaScript (optionally handle dialogs: accept, dismiss, or prompt text) | | `snapshot` | Accessibility tree dump | +| `take-heapsnapshot --output ` | Capture V8 heap snapshot (streamed via CDP) | +| `inspect-heapsnapshot-node --file-path --node-id ` | Inspect a node in a local `.heapsnapshot` file (offline, no Chrome needed) | ### Interaction @@ -226,12 +229,10 @@ The daemon keeps a persistent CDP session on the current page to: - Re-attach to a new target when `--target` changes (the previous target's event buffers are discarded on the switch). ## Source layout - --``` -+```text - src/ - ├── main.rs # Entry point + daemon dispatch +```text +src/ +├── main.rs # Entry point + daemon dispatch ├── lib.rs # CLI (clap) + command routing ├── cdp.rs # Raw CDP over WebSocket (JSON-RPC) + persistent session ├── browser.rs # Auto-connect (DevToolsActivePort) @@ -251,6 +252,7 @@ The daemon keeps a persistent CDP session on the current page to: ├── screenshot.rs ├── snapshot.rs ├── read_page.rs # read-page (Readability extraction + HTML→Markdown) + ├── memory.rs # take-heapsnapshot (CDP streaming) + inspect-heapsnapshot-node (offline) ├── evaluate.rs ├── executor.rs # Command dispatch + persistent-session reuse ├── input.rs # click/fill/type/press/hover From c9e099dc0f22fb700ce6d8d0c56b38a1f164c14a Mon Sep 17 00:00:00 2001 From: Aero Date: Fri, 26 Jun 2026 21:20:25 +0800 Subject: [PATCH 16/20] refactor: remove unused inspect_heapsnapshot_node command and adjust output format for node details --- src/commands/executor.rs | 9 --------- src/commands/memory.rs | 25 ++++++------------------- src/commands/screenshot.rs | 7 ++++++- tests/telemetry_tests.rs | 4 +++- 4 files changed, 15 insertions(+), 30 deletions(-) diff --git a/src/commands/executor.rs b/src/commands/executor.rs index fca7322..63055ad 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -184,15 +184,6 @@ pub async fn execute_command(client: &mut CdpClient, req: &DaemonRequest) -> Res commands::sw_logs::collect_sw_logs(client, duration, extension_id, req.format()) .await } - "inspect-heapsnapshot-node" => match ( - args.get("file_path").and_then(|v| v.as_str()), - args.get("node_id").and_then(|v| v.as_u64()), - ) { - (Some(file_path), Some(node_id)) => { - commands::memory::inspect_heapsnapshot_node(client, "", file_path, node_id, req.format()).await - } - _ => bail!("file_path and node_id required"), - }, "kill-daemon" => Ok(CommandResult::output( "kill-daemon is handled directly by the CLI, not the daemon.", )), diff --git a/src/commands/memory.rs b/src/commands/memory.rs index dcfe536..712922d 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -210,14 +210,14 @@ pub fn format_node_details( ) -> Result { if format.is_text() { let mut out = String::new(); - out.push_str("nodeId,nodeName,selfSize,retainedSize\n"); + out.push_str("nodeId,nodeName,selfSize\n"); let escaped_name = if name.contains(',') || name.contains('"') || name.contains('\n') || name.contains('\r') { format!("\"{}\"", name.replace('"', "\"\"")) } else { name.to_string() }; out.push_str(&format!( - "{},{},{} B,unknown\n", + "{},{},{}\n", node_id, escaped_name, self_size )); Ok(out) @@ -226,24 +226,11 @@ pub fn format_node_details( "nodeId": node_id, "nodeName": name, "selfSize": self_size, - "retainedSize": null }); Ok(crate::format::format_structured(&details, format)?) } } -/// Look up details of a specific node from a local heap snapshot. -/// Adheres to the command-function contract, accepting client, session_id, and OutputFormat. -pub async fn inspect_heapsnapshot_node( - _client: &mut CdpClient, - _session_id: &str, - file_path: &str, - node_id: u64, - format: crate::format::OutputFormat, -) -> Result { - inspect_heapsnapshot_node_offline(file_path, node_id, format).await -} - /// Offline variant that doesn't require a Chrome connection. Used by the CLI's /// early-intercept path so `inspect-heapsnapshot-node` works without a running /// browser or daemon. @@ -345,19 +332,19 @@ mod tests { // Regular name let out_normal = format_node_details(123, "MyClass", 100, OutputFormat::Text).unwrap(); - assert_eq!(out_normal, "nodeId,nodeName,selfSize,retainedSize\n123,MyClass,100 B,unknown\n"); + assert_eq!(out_normal, "nodeId,nodeName,selfSize\n123,MyClass,100\n"); // Name with comma let out_comma = format_node_details(123, "My,Class", 100, OutputFormat::Text).unwrap(); - assert_eq!(out_comma, "nodeId,nodeName,selfSize,retainedSize\n123,\"My,Class\",100 B,unknown\n"); + assert_eq!(out_comma, "nodeId,nodeName,selfSize\n123,\"My,Class\",100\n"); // Name with quotes let out_quotes = format_node_details(123, "My\"Class", 100, OutputFormat::Text).unwrap(); - assert_eq!(out_quotes, "nodeId,nodeName,selfSize,retainedSize\n123,\"My\"\"Class\",100 B,unknown\n"); + assert_eq!(out_quotes, "nodeId,nodeName,selfSize\n123,\"My\"\"Class\",100\n"); // Name with newline let out_nl = format_node_details(123, "My\nClass", 100, OutputFormat::Text).unwrap(); - assert_eq!(out_nl, "nodeId,nodeName,selfSize,retainedSize\n123,\"My\nClass\",100 B,unknown\n"); + assert_eq!(out_nl, "nodeId,nodeName,selfSize\n123,\"My\nClass\",100\n"); } #[test] diff --git a/src/commands/screenshot.rs b/src/commands/screenshot.rs index f783154..955a44d 100644 --- a/src/commands/screenshot.rs +++ b/src/commands/screenshot.rs @@ -33,9 +33,14 @@ pub async fn take_screenshot( let format = format.to_ascii_lowercase(); let mut params = json!({ "format": format, - "optimizeForSpeed": true, }); + // optimizeForSpeed trades compression/quality for speed, which would override + // an explicit --quality setting; only enable it when quality isn't requested. + if quality.is_none() { + params["optimizeForSpeed"] = json!(true); + } + if let Some(q) = quality { if format != "png" { params["quality"] = json!(q.min(100)); diff --git a/tests/telemetry_tests.rs b/tests/telemetry_tests.rs index 5c39f3f..af53385 100644 --- a/tests/telemetry_tests.rs +++ b/tests/telemetry_tests.rs @@ -2,7 +2,9 @@ #[test] fn test_telemetry_module_accessible() { use chrome_devtools_cli::telemetry; - assert!(true); + // Reference the module so the import isn't flagged unused; this test exists + // to assert the module path compiles (i.e. is publicly exported). + let _ = telemetry::init_logger_once; } /// Test that TelemetryLogger writes a valid JSON entry and cleans up. From 129fe2952fb1dac60809374d809bf3c8b4859027 Mon Sep 17 00:00:00 2001 From: Aero Date: Fri, 26 Jun 2026 21:40:27 +0800 Subject: [PATCH 17/20] fix: improve path resolution error handling and ensure valid dimensions for screenshots --- README.md | 4 ++-- src/commands/screenshot.rs | 10 +++++---- src/lib.rs | 42 +++++++++++++++++++++----------------- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 9eb6c72..72ab50a 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,8 @@ You can also use `--page ` for quick one-offs, or pass the raw hex target | `screenshot --output ` | Save screenshot to file | | `screenshot --full-page` | Capture full scrollable page | | `screenshot --max-width --max-height ` | Downscale screenshot to fit within dimensions | -| `read-page` | Read page content as clean markdown (extracts main article) | -| `read-page --output ` | Save markdown to file | +| `read-page` | Read page content as clean Markdown (extracts main article) | +| `read-page --output ` | Save Markdown to file | | `evaluate [--dialog-action ]` | Run JavaScript (optionally handle dialogs: accept, dismiss, or prompt text) | | `snapshot` | Accessibility tree dump | | `take-heapsnapshot --output ` | Capture V8 heap snapshot (streamed via CDP) | diff --git a/src/commands/screenshot.rs b/src/commands/screenshot.rs index 955a44d..b094243 100644 --- a/src/commands/screenshot.rs +++ b/src/commands/screenshot.rs @@ -69,14 +69,16 @@ pub async fn take_screenshot( if full_page { // contentSize is the full scrollable content area. if let Some(size) = metrics.get("contentSize") { - src_w = size["width"].as_f64().unwrap_or(1920.0); - src_h = size["height"].as_f64().unwrap_or(1080.0); + // Filter non-positive values (empty/unrendered pages, certain + // document types) — they'd produce an invalid CDP clip. + src_w = size["width"].as_f64().filter(|&v| v > 0.0).unwrap_or(1920.0); + src_h = size["height"].as_f64().filter(|&v| v > 0.0).unwrap_or(1080.0); } } else { // layoutViewport.clientWidth/Height is the visible viewport. if let Some(viewport) = metrics.get("layoutViewport") { - src_w = viewport["clientWidth"].as_f64().unwrap_or(1920.0); - src_h = viewport["clientHeight"].as_f64().unwrap_or(1080.0); + src_w = viewport["clientWidth"].as_f64().filter(|&v| v > 0.0).unwrap_or(1920.0); + src_h = viewport["clientHeight"].as_f64().filter(|&v| v > 0.0).unwrap_or(1080.0); } } } diff --git a/src/lib.rs b/src/lib.rs index 8f89daf..a41c692 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -382,24 +382,28 @@ impl Cli { /// Resolve a relative path to an absolute one using the CLI process's CWD. /// The daemon retains its own startup CWD, so relative paths sent to it would /// resolve against the wrong directory. -fn absolutize_path(path: &str) -> String { +fn absolutize_path(path: &str) -> Result { let p = std::path::Path::new(path); if p.is_absolute() { - path.to_string() + Ok(path.to_string()) } else { - std::env::current_dir() - .unwrap_or_default() - .join(p) - .to_string_lossy() - .to_string() + // Fail loudly if the CWD can't be resolved: silently falling back to an + // empty path would send a bogus relative path to the daemon, which would + // resolve it against the daemon's (different) startup CWD. + let cwd = std::env::current_dir() + .map_err(|e| anyhow::anyhow!("Failed to resolve CLI working directory to absolutize path '{path}': {e}"))?; + Ok(cwd.join(p).to_string_lossy().to_string()) } } -fn build_request(cli: &Cli) -> DaemonRequest { +fn build_request(cli: &Cli) -> Result { // Resolve relative file paths to absolute so the daemon (which retains its // own startup CWD) resolves them correctly. - let absolutize = |p: &Option| -> Option { - p.as_ref().map(|s| absolutize_path(s)) + let absolutize = |p: &Option| -> Result> { + match p { + None => Ok(None), + Some(s) => Ok(Some(absolutize_path(s)?)), + } }; let (command, args) = match &cli.command { @@ -431,7 +435,7 @@ fn build_request(cli: &Cli) -> DaemonRequest { "geolocation": geolocation, "accuracy": accuracy, "clear_all": clear_all, - "output": absolutize(output) + "output": absolutize(output)? }), ), Commands::NewPage { @@ -462,7 +466,7 @@ fn build_request(cli: &Cli) -> DaemonRequest { } Commands::TakeHeapSnapshot { output } => ( "take-heapsnapshot", - json!({ "output": absolutize_path(output) }), + json!({ "output": absolutize_path(output)? }), ), Commands::Screenshot { output, @@ -474,7 +478,7 @@ fn build_request(cli: &Cli) -> DaemonRequest { } => ( "screenshot", json!({ - "output": absolutize(output), + "output": absolutize(output)?, "format": format, "full_page": full_page, "quality": quality, @@ -489,7 +493,7 @@ fn build_request(cli: &Cli) -> DaemonRequest { track_navigation, } => ( "evaluate", - json!({"expression": expression, "dialog_action": dialog_action, "output": absolutize(output), "track_navigation": track_navigation}), + json!({"expression": expression, "dialog_action": dialog_action, "output": absolutize(output)?, "track_navigation": track_navigation}), ), Commands::Click { selector } => ("click", json!({"selector": selector})), Commands::ClickAt { x, y } => ("click-at", json!({"x": x, "y": y})), @@ -501,8 +505,8 @@ fn build_request(cli: &Cli) -> DaemonRequest { } Commands::PressKey { key } => ("press-key", json!({"key": key})), Commands::Hover { selector } => ("hover", json!({"selector": selector})), - Commands::Snapshot { output } => ("snapshot", json!({"output": absolutize(output)})), - Commands::ReadPage { output } => ("read-page", json!({"output": absolutize(output)})), + Commands::Snapshot { output } => ("snapshot", json!({"output": absolutize(output)?})), + Commands::ReadPage { output } => ("read-page", json!({"output": absolutize(output)?})), Commands::Emulate { viewport, device_scale_factor, @@ -543,7 +547,7 @@ fn build_request(cli: &Cli) -> DaemonRequest { } }; - DaemonRequest { + Ok(DaemonRequest { command: command.to_string(), args, page: cli.page, @@ -552,7 +556,7 @@ fn build_request(cli: &Cli) -> DaemonRequest { output_format: Some(cli.output_format()), block_url: cli.block_url.clone(), unblock_url: cli.unblock_url.clone(), - } + }) } fn print_output(output: &str, navigated_to: Option<&str>, target_id: Option<&str>) { @@ -726,7 +730,7 @@ pub async fn run() -> Result<()> { &cli.channel, )?; - let request = build_request(&cli); + let request = build_request(&cli)?; // Try daemon first if let Ok(resp) = client::send_to_daemon(&request).await { From cfccabb38edcd7e2f9795cc0d20de164bd5ba96f Mon Sep 17 00:00:00 2001 From: Aero Date: Sat, 27 Jun 2026 13:35:37 +0800 Subject: [PATCH 18/20] feat: enhance screenshot functionality with scroll offsets for non-full-page captures --- src/commands/executor.rs | 9 ++++++++- src/commands/screenshot.rs | 17 +++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/commands/executor.rs b/src/commands/executor.rs index 63055ad..f12e1c0 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -106,8 +106,15 @@ fn validate_args(cmd: &str, args: &serde_json::Value) -> Result<()> { } /// Whether a command operates at the browser level (no page session needed). +/// +/// `inspect-heapsnapshot-node` is intentionally excluded: it is intercepted +/// offline in the CLI before any daemon connection is established, so the +/// daemon should never receive it. If it ever does, omitting it here lets it +/// fall through to `inner_execute`'s catch-all `bail!("Unknown command")` +/// rather than hitting the `_ => unreachable!()` arm in the browser-level +/// dispatch and panicking. fn is_browser_level(cmd: &str) -> bool { - matches!(cmd, "list-pages" | "new-page" | "sw-logs" | "inspect-heapsnapshot-node" | "kill-daemon") + matches!(cmd, "list-pages" | "new-page" | "sw-logs" | "kill-daemon") } /// Execute a single command from a [`DaemonRequest`]. diff --git a/src/commands/screenshot.rs b/src/commands/screenshot.rs index b094243..8d44ec4 100644 --- a/src/commands/screenshot.rs +++ b/src/commands/screenshot.rs @@ -51,6 +51,11 @@ pub async fn take_screenshot( // (full-page capture, or downscaling via max_width/max_height). let mut src_w = 1920.0; let mut src_h = 1080.0; + // Scroll offsets of the layout viewport. Clip x/y are relative to the + // document origin, so a non-full-page capture of a scrolled viewport must + // use these to frame the visible region. + let mut scroll_x = 0.0; + let mut scroll_y = 0.0; let needs_metrics = full_page || max_width.is_some() || max_height.is_some(); if needs_metrics { @@ -79,18 +84,22 @@ pub async fn take_screenshot( if let Some(viewport) = metrics.get("layoutViewport") { src_w = viewport["clientWidth"].as_f64().filter(|&v| v > 0.0).unwrap_or(1920.0); src_h = viewport["clientHeight"].as_f64().filter(|&v| v > 0.0).unwrap_or(1080.0); + scroll_x = viewport["pageX"].as_f64().unwrap_or(0.0); + scroll_y = viewport["pageY"].as_f64().unwrap_or(0.0); } } } let clip_scale = clip_scale_factor(src_w, src_h, max_width, max_height); - // Clip coordinates are relative to the captured region's top-left - // (the document origin for full-page, the viewport origin otherwise), - // so x/y are always 0.0 — document scroll offsets must NOT be used here. + // Clip coordinates are relative to the document origin. For full-page + // captures the region starts at the document origin (scroll is irrelevant + // since the whole content is captured). For viewport captures with + // downscaling, the layout viewport's scroll offsets (pageX/pageY) must be + // used so the visible region — not the document's top-left — is framed. if full_page || clip_scale < 1.0 { params["clip"] = json!({ - "x": 0.0, "y": 0.0, + "x": scroll_x, "y": scroll_y, "width": src_w, "height": src_h, "scale": clip_scale, }); From fb502b0c0349e9d18350e6d0ad7296e3a41b668b Mon Sep 17 00:00:00 2001 From: Aero Date: Sat, 27 Jun 2026 13:50:09 +0800 Subject: [PATCH 19/20] fix: ensure temporary heap snapshot files are removed on cancellation and errors --- src/commands/memory.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/commands/memory.rs b/src/commands/memory.rs index 712922d..35a57d8 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -25,6 +25,21 @@ pub async fn take_heapsnapshot( output_path.file_name().unwrap_or_default().to_string_lossy(), std::process::id(), )); + // Drop guard ensures the temp file is removed under all termination paths + // — including future cancellation (timeout, client disconnect, Ctrl+C) and + // panics — where the async cleanup below would never run. On the success + // path the file has been renamed away, so `remove_file` is a harmless no-op. + struct TempFileGuard { + path: std::path::PathBuf, + } + impl Drop for TempFileGuard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } + } + let _guard = TempFileGuard { + path: temp_path.clone(), + }; // Heap snapshots can be tens or hundreds of MB; buffer the writes to avoid a // syscall per streamed chunk. let mut file = tokio::io::BufWriter::new( @@ -93,8 +108,6 @@ pub async fn take_heapsnapshot( let _ = client.send_to_target(session_id, "HeapProfiler.disable", json!({})).await; if let Err(e) = snapshot_result { - // Clean up the partial temp file so a failed run leaves no artifacts. - let _ = tokio::fs::remove_file(&temp_path).await; return Err(e); } From 3019bf19b846bb9d3e2733086453f29beceead4c Mon Sep 17 00:00:00 2001 From: Aero Date: Sat, 27 Jun 2026 14:39:54 +0800 Subject: [PATCH 20/20] fix: ensure proper file handle release before renaming heap snapshot file --- src/commands/memory.rs | 5 +++++ src/commands/screenshot.rs | 11 +++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/commands/memory.rs b/src/commands/memory.rs index 35a57d8..e56185d 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -111,6 +111,11 @@ pub async fn take_heapsnapshot( return Err(e); } + // Drop the writer (and its underlying file handle) before the rename: on + // Windows an open handle blocks the move, and even on Unix releasing it + // before the atomic rename is the safe, portable ordering. + drop(file); + // Atomically move the completed temp file to the final output path. tokio::fs::rename(&temp_path, output_path) .await diff --git a/src/commands/screenshot.rs b/src/commands/screenshot.rs index 8d44ec4..a95f19f 100644 --- a/src/commands/screenshot.rs +++ b/src/commands/screenshot.rs @@ -72,16 +72,19 @@ pub async fn take_screenshot( .context("Failed to query page layout metrics")?; if full_page { - // contentSize is the full scrollable content area. - if let Some(size) = metrics.get("contentSize") { + // cssContentSize is the full scrollable content area in CSS pixels. + // (The legacy `contentSize` returns DIPs, which are wrong on HiDPI + // or emulated devices.) + if let Some(size) = metrics.get("cssContentSize") { // Filter non-positive values (empty/unrendered pages, certain // document types) — they'd produce an invalid CDP clip. src_w = size["width"].as_f64().filter(|&v| v > 0.0).unwrap_or(1920.0); src_h = size["height"].as_f64().filter(|&v| v > 0.0).unwrap_or(1080.0); } } else { - // layoutViewport.clientWidth/Height is the visible viewport. - if let Some(viewport) = metrics.get("layoutViewport") { + // cssLayoutViewport.clientWidth/Height is the visible viewport in + // CSS pixels; pageX/pageY are its document-origin scroll offsets. + if let Some(viewport) = metrics.get("cssLayoutViewport") { src_w = viewport["clientWidth"].as_f64().filter(|&v| v > 0.0).unwrap_or(1920.0); src_h = viewport["clientHeight"].as_f64().filter(|&v| v > 0.0).unwrap_or(1080.0); scroll_x = viewport["pageX"].as_f64().unwrap_or(0.0);