use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use agentchrome::cdp::CdpClient;
use agentchrome::connection::ManagedSession;
use agentchrome::error::{AppError, ExitCode};
use crate::cli::{
GlobalOpts, NetworkArgs, NetworkCommand, NetworkFollowArgs, NetworkGetArgs, NetworkListArgs,
};
use crate::output::connect_from_global;
#[derive(Clone, Debug, Serialize)]
pub struct NetworkRequestSummary {
id: usize,
method: String,
url: String,
status: Option<u16>,
#[serde(rename = "type")]
resource_type: String,
size: Option<u64>,
duration_ms: Option<f64>,
timestamp: String,
}
#[derive(Debug, Serialize)]
struct NetworkRequestDetail {
id: usize,
request: RequestInfo,
response: ResponseInfo,
timing: TimingInfo,
#[serde(rename = "redirect_chain")]
redirect_chain: Vec<RedirectEntry>,
#[serde(rename = "type")]
resource_type: String,
size: Option<u64>,
duration_ms: Option<f64>,
timestamp: String,
}
#[derive(Debug, Serialize)]
struct RequestInfo {
method: String,
url: String,
headers: serde_json::Value,
body: Option<String>,
}
#[derive(Debug, Serialize)]
struct ResponseInfo {
status: Option<u16>,
status_text: String,
headers: serde_json::Value,
body: Option<String>,
binary: bool,
truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
mime_type: Option<String>,
}
#[allow(clippy::struct_field_names)]
#[derive(Debug, Serialize)]
struct TimingInfo {
dns_ms: f64,
connect_ms: f64,
tls_ms: f64,
ttfb_ms: f64,
download_ms: f64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct RedirectEntry {
url: String,
status: u16,
}
#[derive(Debug, Serialize)]
struct NetworkStreamEvent {
method: String,
url: String,
status: Option<u16>,
#[serde(rename = "type")]
resource_type: String,
size: Option<u64>,
duration_ms: Option<f64>,
timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
request_headers: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
response_headers: Option<serde_json::Value>,
}
struct RawNetworkEvent {
params: serde_json::Value,
event_type: NetworkEventType,
navigation_id: u32,
}
#[derive(Clone, Copy)]
enum NetworkEventType {
RequestWillBeSent,
ResponseReceived,
LoadingFinished,
LoadingFailed,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct NetworkRequestBuilder {
cdp_request_id: String,
assigned_id: usize,
method: String,
url: String,
resource_type: String,
timestamp: f64,
wall_time: f64,
request_headers: serde_json::Value,
status: Option<u16>,
status_text: String,
response_headers: serde_json::Value,
mime_type: Option<String>,
encoded_data_length: Option<u64>,
timing: Option<serde_json::Value>,
redirect_chain: Vec<RedirectEntry>,
completed: bool,
failed: bool,
error_text: Option<String>,
navigation_id: u32,
loading_finished_timestamp: Option<f64>,
frame_id: Option<String>,
}
#[cfg(test)]
fn print_list_plain(requests: &[NetworkRequestSummary]) {
for req in requests {
let status_str = req
.status
.map_or_else(|| "---".to_string(), |s| s.to_string());
let size_str = req
.size
.map_or_else(|| "-".to_string(), |s| format!("{s}B"));
let dur_str = req
.duration_ms
.map_or_else(|| "-".to_string(), |d| format!("{d:.1}ms"));
println!(
"{} {} {} {} {}",
req.method, req.url, status_str, size_str, dur_str
);
}
}
fn format_detail_plain(detail: &NetworkRequestDetail) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "{} {}", detail.request.method, detail.request.url);
let status_str = detail
.response
.status
.map_or_else(|| "---".to_string(), |s| s.to_string());
let _ = writeln!(
out,
" Status: {} {}",
status_str, detail.response.status_text
);
let _ = writeln!(out, " Type: {}", detail.resource_type);
let _ = writeln!(out, " Timestamp: {}", detail.timestamp);
if let Some(size) = detail.size {
let _ = writeln!(out, " Size: {size} bytes");
}
if let Some(dur) = detail.duration_ms {
let _ = writeln!(out, " Duration: {dur:.1}ms");
}
let _ = writeln!(
out,
" Timing: DNS={:.1}ms Connect={:.1}ms TLS={:.1}ms TTFB={:.1}ms Download={:.1}ms",
detail.timing.dns_ms,
detail.timing.connect_ms,
detail.timing.tls_ms,
detail.timing.ttfb_ms,
detail.timing.download_ms,
);
if !detail.redirect_chain.is_empty() {
let _ = writeln!(out, " Redirects:");
for hop in &detail.redirect_chain {
let _ = writeln!(out, " {} -> {}", hop.status, hop.url);
}
}
out
}
#[cfg(test)]
fn print_detail_plain(detail: &NetworkRequestDetail) {
print!("{}", format_detail_plain(detail));
}
const MAX_INLINE_BODY_SIZE: usize = 10_000;
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::similar_names
)]
fn timestamp_to_iso(ts: f64) -> String {
let total_ms = (ts * 1000.0) as u64;
let secs = total_ms / 1000;
let ms_part = total_ms % 1000;
let days_since_epoch = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let z = days_since_epoch as i64 + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}.{ms_part:03}Z")
}
fn parse_status_filter(status_str: &str) -> StatusFilter {
let lower = status_str.to_lowercase();
if lower.len() == 3
&& lower.ends_with("xx")
&& let Some(prefix_char) = lower.chars().next()
&& let Some(digit) = prefix_char.to_digit(10)
{
#[allow(clippy::cast_possible_truncation)]
let base = (digit as u16) * 100;
return StatusFilter::Range(base, base + 99);
}
if let Ok(code) = status_str.parse::<u16>() {
StatusFilter::Exact(code)
} else {
StatusFilter::Exact(0)
}
}
enum StatusFilter {
Exact(u16),
Range(u16, u16),
}
impl StatusFilter {
fn matches(&self, code: u16) -> bool {
match self {
Self::Exact(target) => code == *target,
Self::Range(low, high) => code >= *low && code <= *high,
}
}
}
fn resolve_type_filter(type_arg: Option<&str>) -> Option<Vec<String>> {
type_arg.map(|types| types.split(',').map(|t| t.trim().to_lowercase()).collect())
}
fn filter_by_type(
requests: Vec<NetworkRequestSummary>,
types: &[String],
) -> Vec<NetworkRequestSummary> {
requests
.into_iter()
.filter(|r| types.iter().any(|t| t == &r.resource_type.to_lowercase()))
.collect()
}
fn filter_by_url(
requests: Vec<NetworkRequestSummary>,
pattern: &str,
) -> Vec<NetworkRequestSummary> {
requests
.into_iter()
.filter(|r| r.url.contains(pattern))
.collect()
}
fn filter_by_status(
requests: Vec<NetworkRequestSummary>,
status_filter: &StatusFilter,
) -> Vec<NetworkRequestSummary> {
requests
.into_iter()
.filter(|r| r.status.is_some_and(|s| status_filter.matches(s)))
.collect()
}
fn filter_by_method(
requests: Vec<NetworkRequestSummary>,
method: &str,
) -> Vec<NetworkRequestSummary> {
let upper = method.to_uppercase();
requests
.into_iter()
.filter(|r| r.method.to_uppercase() == upper)
.collect()
}
fn extract_domain(url: &str) -> Option<String> {
let without_scheme = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url);
let host = without_scheme.split('/').next()?;
let host = host.split(':').next()?;
if host.is_empty() {
None
} else {
Some(host.to_string())
}
}
fn paginate(
requests: Vec<NetworkRequestSummary>,
limit: usize,
page: usize,
) -> Vec<NetworkRequestSummary> {
let offset = page * limit;
requests.into_iter().skip(offset).take(limit).collect()
}
fn extract_timing(timing: &serde_json::Value) -> TimingInfo {
let dns_start = timing["dnsStart"].as_f64().unwrap_or(-1.0);
let dns_end = timing["dnsEnd"].as_f64().unwrap_or(-1.0);
let connect_start = timing["connectStart"].as_f64().unwrap_or(-1.0);
let connect_end = timing["connectEnd"].as_f64().unwrap_or(-1.0);
let ssl_start = timing["sslStart"].as_f64().unwrap_or(-1.0);
let ssl_end = timing["sslEnd"].as_f64().unwrap_or(-1.0);
let send_end = timing["sendEnd"].as_f64().unwrap_or(-1.0);
let receive_headers_end = timing["receiveHeadersEnd"].as_f64().unwrap_or(-1.0);
let dns_ms = if dns_start >= 0.0 && dns_end >= 0.0 {
dns_end - dns_start
} else {
0.0
};
let connect_ms = if connect_start >= 0.0 && connect_end >= 0.0 {
connect_end - connect_start
} else {
0.0
};
let tls_ms = if ssl_start >= 0.0 && ssl_end >= 0.0 {
ssl_end - ssl_start
} else {
0.0
};
let ttfb_ms = if send_end >= 0.0 && receive_headers_end >= 0.0 {
receive_headers_end - send_end
} else {
0.0
};
TimingInfo {
dns_ms,
connect_ms,
tls_ms,
ttfb_ms,
download_ms: 0.0, }
}
fn is_binary_mime(mime: &str) -> bool {
let lower = mime.to_lowercase();
lower.starts_with("image/")
|| lower.starts_with("audio/")
|| lower.starts_with("video/")
|| lower.starts_with("application/octet-stream")
|| lower.starts_with("application/zip")
|| lower.starts_with("application/gzip")
|| lower.starts_with("application/pdf")
|| lower.starts_with("font/")
|| lower.starts_with("application/wasm")
}
fn save_body_to_file(path: &Path, content: &str) -> Result<(), AppError> {
std::fs::write(path, content).map_err(|e| AppError {
message: format!("Failed to write to {}: {e}", path.display()),
code: ExitCode::GeneralError,
custom_json: None,
})
}
fn save_binary_body_to_file(path: &Path, base64_content: &str) -> Result<(), AppError> {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(base64_content)
.map_err(|e| AppError {
message: format!("Failed to decode base64 body: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
std::fs::write(path, bytes).map_err(|e| AppError {
message: format!("Failed to write to {}: {e}", path.display()),
code: ExitCode::GeneralError,
custom_json: None,
})
}
const DEFAULT_RELOAD_TIMEOUT_MS: u64 = 5000;
const POST_LOAD_IDLE_MS: u64 = 200;
const CURRENT_CAPTURE_WALL_TIME_GRACE_SECS: f64 = 2.0;
const NETWORK_SNAPSHOT_TTL_SECS: u64 = 300;
const NETWORK_SNAPSHOT_VERSION: u8 = 1;
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
struct NetworkTargetContext {
host: String,
port: u16,
target_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct NetworkSnapshot {
version: u8,
context: NetworkTargetContext,
captured_at_epoch_secs: u64,
requests: Vec<NetworkRequestBuilder>,
}
enum SnapshotLookup {
Hit(Box<NetworkRequestBuilder>),
FreshMiss,
MissingOrStale,
}
async fn setup_network_session(
global: &GlobalOpts,
) -> Result<(CdpClient, ManagedSession, NetworkTargetContext), AppError> {
let conn = connect_from_global(global).await?;
let target = agentchrome::connection::resolve_target(
&conn.resolved.host,
conn.resolved.port,
global.tab.as_deref(),
global.page_id.as_deref(),
)
.await?;
let target_id = target.id.clone();
let session = conn.client.create_session(&target_id).await?;
let mut managed = ManagedSession::new(session);
crate::emulate::apply_emulate_state(&mut managed).await?;
Ok((
conn.client,
managed,
NetworkTargetContext {
host: conn.resolved.host,
port: conn.resolved.port,
target_id,
},
))
}
fn unix_now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn unix_now_secs_f64() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
fn snapshot_file_path() -> Result<std::path::PathBuf, AppError> {
let session_path = agentchrome::session::session_file_path()?;
Ok(session_path.with_file_name("network-snapshot.json"))
}
fn app_io_error(message: String) -> AppError {
AppError {
message,
code: ExitCode::GeneralError,
custom_json: None,
}
}
fn network_request_not_found(target_id: usize) -> AppError {
AppError {
message: format!("Network request {target_id} not found"),
code: ExitCode::GeneralError,
custom_json: None,
}
}
#[cfg(unix)]
fn set_owner_only_perms(path: &std::path::Path, mode: u32) -> Result<(), AppError> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|e| {
app_io_error(format!(
"Failed to set permissions on {}: {e}",
path.display()
))
})
}
#[cfg(not(unix))]
fn set_owner_only_perms(_path: &std::path::Path, _mode: u32) -> Result<(), AppError> {
Ok(())
}
fn write_network_snapshot(
context: &NetworkTargetContext,
requests: &[NetworkRequestBuilder],
) -> Result<(), AppError> {
let path = snapshot_file_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
app_io_error(format!(
"Failed to create network snapshot directory {}: {e}",
parent.display()
))
})?;
set_owner_only_perms(parent, 0o700)?;
}
let snapshot = NetworkSnapshot {
version: NETWORK_SNAPSHOT_VERSION,
context: context.clone(),
captured_at_epoch_secs: unix_now_secs(),
requests: requests.to_vec(),
};
let json = serde_json::to_vec_pretty(&snapshot)
.map_err(|e| app_io_error(format!("Failed to serialize network snapshot: {e}")))?;
let tmp_path = path.with_extension("json.tmp");
std::fs::write(&tmp_path, json).map_err(|e| {
app_io_error(format!(
"Failed to write network snapshot {}: {e}",
tmp_path.display()
))
})?;
set_owner_only_perms(&tmp_path, 0o600)?;
std::fs::rename(&tmp_path, &path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
app_io_error(format!(
"Failed to finalize network snapshot {}: {e}",
path.display()
))
})
}
fn read_network_snapshot() -> Result<Option<NetworkSnapshot>, AppError> {
let path = snapshot_file_path()?;
match std::fs::read_to_string(&path) {
Ok(contents) => Ok(serde_json::from_str(&contents).ok()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(app_io_error(format!(
"Failed to read network snapshot {}: {e}",
path.display()
))),
}
}
fn lookup_snapshot_request(
snapshot: Option<NetworkSnapshot>,
context: &NetworkTargetContext,
target_id: usize,
now_epoch_secs: u64,
) -> SnapshotLookup {
let Some(snapshot) = snapshot else {
return SnapshotLookup::MissingOrStale;
};
if snapshot.version != NETWORK_SNAPSHOT_VERSION || snapshot.context != *context {
return SnapshotLookup::MissingOrStale;
}
if now_epoch_secs.saturating_sub(snapshot.captured_at_epoch_secs) > NETWORK_SNAPSHOT_TTL_SECS {
return SnapshotLookup::MissingOrStale;
}
snapshot
.requests
.into_iter()
.find(|request| request.assigned_id == target_id)
.map_or(SnapshotLookup::FreshMiss, |request| {
SnapshotLookup::Hit(Box::new(request))
})
}
#[allow(clippy::too_many_lines)]
async fn collect_and_correlate(
managed: &mut ManagedSession,
include_preserved: bool,
timeout_ms: Option<u64>,
) -> Result<(Vec<NetworkRequestBuilder>, u32), AppError> {
let total_timeout = Duration::from_millis(timeout_ms.unwrap_or(DEFAULT_RELOAD_TIMEOUT_MS));
managed.ensure_domain("Network").await?;
managed.ensure_domain("Page").await?;
let mut request_rx = managed
.subscribe("Network.requestWillBeSent")
.await
.map_err(|e| AppError {
message: format!("Failed to subscribe to Network.requestWillBeSent: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
let mut response_rx = managed
.subscribe("Network.responseReceived")
.await
.map_err(|e| AppError {
message: format!("Failed to subscribe to Network.responseReceived: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
let mut finished_rx = managed
.subscribe("Network.loadingFinished")
.await
.map_err(|e| AppError {
message: format!("Failed to subscribe to Network.loadingFinished: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
let mut failed_rx = managed
.subscribe("Network.loadingFailed")
.await
.map_err(|e| AppError {
message: format!("Failed to subscribe to Network.loadingFailed: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
let mut nav_rx = managed
.subscribe("Page.frameNavigated")
.await
.map_err(|e| AppError {
message: format!("Failed to subscribe to Page.frameNavigated: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
let mut load_event_rx = managed
.subscribe("Page.loadEventFired")
.await
.map_err(|e| AppError {
message: format!("Failed to subscribe to Page.loadEventFired: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
let capture_started_epoch_secs = unix_now_secs_f64();
managed
.send_command("Page.reload", Some(serde_json::json!({})))
.await
.map_err(|e| AppError {
message: format!("Failed to reload page: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
let mut raw_events: Vec<RawNetworkEvent> = Vec::new();
let mut current_nav_id: u32 = 0;
let absolute_deadline = tokio::time::Instant::now() + total_timeout;
let mut idle_deadline: Option<tokio::time::Instant> = None;
loop {
let effective_deadline = match idle_deadline {
Some(idle) => idle.min(absolute_deadline),
None => absolute_deadline,
};
let remaining = effective_deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
tokio::select! {
event = request_rx.recv() => {
match event {
Some(ev) => raw_events.push(RawNetworkEvent {
params: ev.params,
event_type: NetworkEventType::RequestWillBeSent,
navigation_id: current_nav_id,
}),
None => break,
}
}
event = response_rx.recv() => {
match event {
Some(ev) => raw_events.push(RawNetworkEvent {
params: ev.params,
event_type: NetworkEventType::ResponseReceived,
navigation_id: current_nav_id,
}),
None => break,
}
}
event = finished_rx.recv() => {
match event {
Some(ev) => raw_events.push(RawNetworkEvent {
params: ev.params,
event_type: NetworkEventType::LoadingFinished,
navigation_id: current_nav_id,
}),
None => break,
}
}
event = failed_rx.recv() => {
match event {
Some(ev) => raw_events.push(RawNetworkEvent {
params: ev.params,
event_type: NetworkEventType::LoadingFailed,
navigation_id: current_nav_id,
}),
None => break,
}
}
event = nav_rx.recv() => {
match event {
Some(_) => current_nav_id += 1,
None => break,
}
}
event = load_event_rx.recv() => {
match event {
Some(_) => {
if idle_deadline.is_none() {
idle_deadline = Some(
tokio::time::Instant::now()
+ tokio::time::Duration::from_millis(POST_LOAD_IDLE_MS),
);
}
}
None => break,
}
}
() = tokio::time::sleep(remaining) => break,
}
}
let builders_vec = correlate_raw_events(
&raw_events,
include_preserved,
current_nav_id,
Some(capture_started_epoch_secs),
);
Ok((builders_vec, current_nav_id))
}
#[derive(Default)]
struct NetworkRequestFragments {
request_events: Vec<(serde_json::Value, u32)>,
response: Option<serde_json::Value>,
finished: Option<serde_json::Value>,
failed: Option<serde_json::Value>,
}
fn correlate_raw_events(
raw_events: &[RawNetworkEvent],
include_preserved: bool,
current_nav_id: u32,
capture_started_epoch_secs: Option<f64>,
) -> Vec<NetworkRequestBuilder> {
let mut fragments_by_request: HashMap<String, NetworkRequestFragments> = HashMap::new();
for event in raw_events {
let request_id = event.params["requestId"].as_str().unwrap_or("");
if request_id.is_empty() {
continue;
}
let fragments = fragments_by_request
.entry(request_id.to_string())
.or_default();
match event.event_type {
NetworkEventType::RequestWillBeSent => fragments
.request_events
.push((event.params.clone(), event.navigation_id)),
NetworkEventType::ResponseReceived => fragments.response = Some(event.params.clone()),
NetworkEventType::LoadingFinished => fragments.finished = Some(event.params.clone()),
NetworkEventType::LoadingFailed => fragments.failed = Some(event.params.clone()),
}
}
let mut builders: Vec<NetworkRequestBuilder> = fragments_by_request
.into_iter()
.filter_map(|(request_id, fragments)| builder_from_fragments(&request_id, fragments))
.filter(|builder| {
include_preserved
|| is_current_capture_request(builder, current_nav_id, capture_started_epoch_secs)
})
.collect();
assign_deterministic_ids(&mut builders);
builders
}
fn builder_from_fragments(
request_id: &str,
fragments: NetworkRequestFragments,
) -> Option<NetworkRequestBuilder> {
let mut request_events = fragments.request_events.into_iter();
let (first_request, first_navigation_id) = request_events.next()?;
let mut builder = builder_from_request_event(request_id, &first_request, first_navigation_id);
for (request, navigation_id) in request_events {
update_builder_from_redirect_or_request(&mut builder, &request, navigation_id);
}
if let Some(response) = fragments.response {
apply_response_event(&mut builder, &response);
}
if let Some(finished) = fragments.finished {
apply_finished_event(&mut builder, &finished);
}
if let Some(failed) = fragments.failed {
apply_failed_event(&mut builder, &failed);
}
Some(builder)
}
fn builder_from_request_event(
request_id: &str,
params: &serde_json::Value,
navigation_id: u32,
) -> NetworkRequestBuilder {
NetworkRequestBuilder {
cdp_request_id: request_id.to_string(),
assigned_id: 0,
method: params["request"]["method"]
.as_str()
.unwrap_or("GET")
.to_string(),
url: params["request"]["url"].as_str().unwrap_or("").to_string(),
resource_type: params["type"].as_str().unwrap_or("Other").to_lowercase(),
timestamp: params["timestamp"].as_f64().unwrap_or(0.0),
wall_time: params["wallTime"].as_f64().unwrap_or(0.0),
request_headers: params["request"]["headers"].clone(),
status: None,
status_text: String::new(),
response_headers: serde_json::Value::Null,
mime_type: None,
encoded_data_length: None,
timing: None,
redirect_chain: Vec::new(),
completed: false,
failed: false,
error_text: None,
navigation_id,
loading_finished_timestamp: None,
frame_id: params["frameId"].as_str().map(String::from),
}
}
fn update_builder_from_redirect_or_request(
builder: &mut NetworkRequestBuilder,
params: &serde_json::Value,
navigation_id: u32,
) {
if let Some(status) = params["redirectResponse"]["status"].as_u64() {
#[allow(clippy::cast_possible_truncation)]
builder.redirect_chain.push(RedirectEntry {
url: builder.url.clone(),
status: status as u16,
});
}
builder.method = params["request"]["method"]
.as_str()
.unwrap_or("GET")
.to_string();
builder.url = params["request"]["url"].as_str().unwrap_or("").to_string();
builder.resource_type = params["type"].as_str().unwrap_or("Other").to_lowercase();
builder.timestamp = params["timestamp"].as_f64().unwrap_or(0.0);
builder.wall_time = params["wallTime"].as_f64().unwrap_or(0.0);
builder.request_headers = params["request"]["headers"].clone();
builder.navigation_id = navigation_id;
builder.frame_id = params["frameId"].as_str().map(String::from);
}
fn apply_response_event(builder: &mut NetworkRequestBuilder, params: &serde_json::Value) {
#[allow(clippy::cast_possible_truncation)]
let status = params["response"]["status"].as_u64().map(|s| s as u16);
builder.status = status;
builder.status_text = params["response"]["statusText"]
.as_str()
.unwrap_or("")
.to_string();
builder.response_headers = params["response"]["headers"].clone();
builder.mime_type = params["response"]["mimeType"].as_str().map(String::from);
builder.timing = Some(params["response"]["timing"].clone());
}
fn apply_finished_event(builder: &mut NetworkRequestBuilder, params: &serde_json::Value) {
builder.completed = true;
builder.encoded_data_length = params["encodedDataLength"].as_u64();
builder.loading_finished_timestamp = params["timestamp"].as_f64();
}
fn apply_failed_event(builder: &mut NetworkRequestBuilder, params: &serde_json::Value) {
builder.failed = true;
builder.error_text = params["errorText"].as_str().map(String::from);
}
fn is_current_capture_request(
builder: &NetworkRequestBuilder,
current_nav_id: u32,
capture_started_epoch_secs: Option<f64>,
) -> bool {
if let Some(started) = capture_started_epoch_secs
&& builder.wall_time > 0.0
&& builder.wall_time + CURRENT_CAPTURE_WALL_TIME_GRACE_SECS >= started
{
return true;
}
builder.navigation_id == current_nav_id
|| (current_nav_id > 0 && builder.navigation_id.saturating_add(1) == current_nav_id)
}
fn assign_deterministic_ids(builders: &mut [NetworkRequestBuilder]) {
builders.sort_by(|left, right| {
left.timestamp
.total_cmp(&right.timestamp)
.then_with(|| left.url.cmp(&right.url))
.then_with(|| left.cdp_request_id.cmp(&right.cdp_request_id))
});
for (index, builder) in builders.iter_mut().enumerate() {
builder.assigned_id = index;
}
}
fn resolve_size(
encoded_data_length: Option<u64>,
response_headers: &serde_json::Value,
) -> Option<u64> {
if let Some(len) = encoded_data_length
&& len > 0
{
return Some(len);
}
if let Some(headers) = response_headers.as_object() {
for (key, value) in headers {
if key.eq_ignore_ascii_case("content-length") {
return value
.as_str()
.and_then(|s| s.parse::<u64>().ok())
.filter(|&v| v > 0);
}
}
}
None
}
fn builder_to_summary(builder: &NetworkRequestBuilder) -> NetworkRequestSummary {
let duration_ms = builder
.loading_finished_timestamp
.map(|end_ts| (end_ts - builder.timestamp) * 1000.0);
NetworkRequestSummary {
id: builder.assigned_id,
method: builder.method.clone(),
url: builder.url.clone(),
status: builder.status,
resource_type: builder.resource_type.clone(),
size: resolve_size(builder.encoded_data_length, &builder.response_headers),
duration_ms,
timestamp: timestamp_to_iso(builder.wall_time),
}
}
fn resolve_frame_id_by_path(
frames: &[agentchrome::frame::FrameInfo],
segments: &[u32],
) -> Option<String> {
if segments.is_empty() {
return frames.iter().find(|f| f.depth == 0).map(|f| f.id.clone());
}
let main = frames.iter().find(|f| f.depth == 0)?;
let mut current_id = main.id.clone();
for &seg in segments {
let current = frames.iter().find(|f| f.id == current_id)?;
let child_id = current.child_ids.get(seg as usize)?.clone();
current_id = child_id;
}
Some(current_id)
}
pub async fn run_from_session(
_managed: &mut ManagedSession,
global: &GlobalOpts,
args: &NetworkArgs,
) -> Result<serde_json::Value, agentchrome::error::AppError> {
execute_network(global, args).await?;
Ok(serde_json::json!({"executed": true}))
}
pub async fn execute_network(global: &GlobalOpts, args: &NetworkArgs) -> Result<(), AppError> {
match &args.command {
NetworkCommand::List(list_args) => execute_list(global, list_args).await,
NetworkCommand::Get(get_args) => execute_get(global, get_args).await,
NetworkCommand::Follow(follow_args) => execute_follow(global, follow_args).await,
}
}
async fn execute_list(global: &GlobalOpts, args: &NetworkListArgs) -> Result<(), AppError> {
let (client, mut managed, context) = setup_network_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
let filter_frame_id: Option<String> = if let Some(ref frame_str) = args.frame {
let frame_arg = agentchrome::frame::parse_frame_arg(frame_str)?;
managed.ensure_domain("Page").await?;
let frames = agentchrome::frame::list_frames(&mut managed).await?;
match frame_arg {
agentchrome::frame::FrameArg::Index(idx) => {
frames.iter().find(|f| f.index == idx).map(|f| f.id.clone())
}
agentchrome::frame::FrameArg::Path(ref segments) => {
resolve_frame_id_by_path(&frames, segments)
}
agentchrome::frame::FrameArg::Auto => None,
}
} else {
None
};
let (builders, _nav_id) =
collect_and_correlate(&mut managed, args.include_preserved, global.timeout).await?;
if let Err(e) = write_network_snapshot(&context, &builders) {
eprintln!(
"warning: could not persist network list snapshot: {}",
e.message
);
}
let builders: Vec<_> = if let Some(ref fid) = filter_frame_id {
builders
.into_iter()
.filter(|b| b.frame_id.as_deref() == Some(fid.as_str()))
.collect()
} else {
builders
};
let mut requests: Vec<NetworkRequestSummary> =
builders.iter().map(builder_to_summary).collect();
requests.sort_by_key(|r| r.id);
if let Some(ref types) = resolve_type_filter(args.r#type.as_deref()) {
requests = filter_by_type(requests, types);
}
if let Some(ref url_pattern) = args.url {
requests = filter_by_url(requests, url_pattern);
}
if let Some(ref status_str) = args.status {
let status_filter = parse_status_filter(status_str);
requests = filter_by_status(requests, &status_filter);
}
if let Some(ref method) = args.method {
requests = filter_by_method(requests, method);
}
let _ = &client;
requests = paginate(requests, args.limit, args.page);
if global.output.plain {
use std::fmt::Write as _;
let mut text = String::new();
for req in &requests {
let status_str = req
.status
.map_or_else(|| "---".to_string(), |s| s.to_string());
let size_str = req
.size
.map_or_else(|| "-".to_string(), |s| format!("{s}B"));
let dur_str = req
.duration_ms
.map_or_else(|| "-".to_string(), |d| format!("{d:.1}ms"));
let _ = writeln!(
text,
"{} {} {} {} {}",
req.method, req.url, status_str, size_str, dur_str
);
}
crate::output::emit_plain(&text, &global.output)?;
return Ok(());
}
crate::output::emit(&requests, &global.output, "network list", |reqs| {
use std::collections::HashSet;
let methods: HashSet<&str> = reqs.iter().map(|r| r.method.as_str()).collect();
let domains: HashSet<String> = reqs.iter().filter_map(|r| extract_domain(&r.url)).collect();
serde_json::json!({
"request_count": reqs.len(),
"methods": methods.into_iter().collect::<Vec<_>>(),
"domains": domains.into_iter().take(10).collect::<Vec<_>>(),
})
})
}
#[allow(clippy::too_many_lines)]
async fn execute_get(global: &GlobalOpts, args: &NetworkGetArgs) -> Result<(), AppError> {
let (_client, mut managed, context) = setup_network_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
#[allow(clippy::cast_possible_truncation)]
let target_id = args.req_id as usize;
let snapshot = read_network_snapshot()?;
let builder = match lookup_snapshot_request(snapshot, &context, target_id, unix_now_secs()) {
SnapshotLookup::Hit(builder) => *builder,
SnapshotLookup::FreshMiss => {
return Err(network_request_not_found(target_id));
}
SnapshotLookup::MissingOrStale => {
let (builders, _nav_id) =
collect_and_correlate(&mut managed, true, global.timeout).await?;
let builder = builders
.iter()
.find(|b| b.assigned_id == target_id)
.cloned()
.ok_or_else(|| network_request_not_found(target_id))?;
if let Err(e) = write_network_snapshot(&context, &builders) {
eprintln!(
"warning: could not persist network get snapshot: {}",
e.message
);
}
builder
}
};
managed
.ensure_domain("Network")
.await
.map_err(|e| AppError {
message: format!("Failed to enable Network domain: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
let builder = &builder;
if builder.assigned_id != target_id {
return Err(network_request_not_found(target_id));
}
let request_body =
if builder.method == "POST" || builder.method == "PUT" || builder.method == "PATCH" {
match managed
.send_command(
"Network.getRequestPostData",
Some(serde_json::json!({ "requestId": &builder.cdp_request_id })),
)
.await
{
Ok(result) => result["postData"].as_str().map(String::from),
Err(_) => None,
}
} else {
None
};
let (response_body, is_binary, is_truncated) = match managed
.send_command(
"Network.getResponseBody",
Some(serde_json::json!({ "requestId": &builder.cdp_request_id })),
)
.await
{
Ok(result) => {
let base64_encoded = result["base64Encoded"].as_bool().unwrap_or(false);
let body_str = result["body"].as_str().unwrap_or("");
if base64_encoded {
if let Some(ref save_path) = args.save_response {
save_binary_body_to_file(save_path, body_str)?;
}
(None, true, false)
} else if body_str.len() > MAX_INLINE_BODY_SIZE {
if let Some(ref save_path) = args.save_response {
save_body_to_file(save_path, body_str)?;
}
let truncated = body_str[..MAX_INLINE_BODY_SIZE].to_string();
(Some(truncated), false, true)
} else {
if let Some(ref save_path) = args.save_response {
save_body_to_file(save_path, body_str)?;
}
(Some(body_str.to_string()), false, false)
}
}
Err(_) => (None, false, false),
};
if let Some(ref save_path) = args.save_request
&& let Some(ref body) = request_body
{
save_body_to_file(save_path, body)?;
}
let timing = builder.timing.as_ref().map_or_else(
|| TimingInfo {
dns_ms: 0.0,
connect_ms: 0.0,
tls_ms: 0.0,
ttfb_ms: 0.0,
download_ms: 0.0,
},
|t| {
let mut ti = extract_timing(t);
if let Some(end_ts) = builder.loading_finished_timestamp {
let request_time = t["requestTime"].as_f64().unwrap_or(0.0);
let receive_headers_end = t["receiveHeadersEnd"].as_f64().unwrap_or(0.0);
if request_time > 0.0 && receive_headers_end > 0.0 {
let headers_done = request_time + receive_headers_end / 1000.0;
ti.download_ms = (end_ts - headers_done) * 1000.0;
if ti.download_ms < 0.0 {
ti.download_ms = 0.0;
}
}
}
ti
},
);
let duration_ms = builder
.loading_finished_timestamp
.map(|end_ts| (end_ts - builder.timestamp) * 1000.0);
let mime_for_binary_check = builder.mime_type.as_deref().unwrap_or("");
let binary = is_binary || is_binary_mime(mime_for_binary_check);
let detail = NetworkRequestDetail {
id: builder.assigned_id,
request: RequestInfo {
method: builder.method.clone(),
url: builder.url.clone(),
headers: builder.request_headers.clone(),
body: request_body,
},
response: ResponseInfo {
status: builder.status,
status_text: builder.status_text.clone(),
headers: builder.response_headers.clone(),
body: if binary { None } else { response_body },
binary,
truncated: is_truncated,
mime_type: builder.mime_type.clone(),
},
timing,
redirect_chain: builder.redirect_chain.clone(),
resource_type: builder.resource_type.clone(),
size: resolve_size(builder.encoded_data_length, &builder.response_headers),
duration_ms,
timestamp: timestamp_to_iso(builder.wall_time),
};
if global.output.plain {
let text = format_detail_plain(&detail);
crate::output::emit_plain(&text, &global.output)?;
return Ok(());
}
crate::output::emit(&detail, &global.output, "network get", |d| {
serde_json::json!({
"url": d.request.url,
"status": d.response.status,
"content_type": d.response.mime_type,
"body_size_bytes": d.response.body.as_ref().map(String::len),
})
})
}
#[allow(clippy::too_many_lines)]
async fn execute_follow(global: &GlobalOpts, args: &NetworkFollowArgs) -> Result<(), AppError> {
let (_client, mut managed, _context) = setup_network_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
managed.ensure_domain("Network").await?;
let mut request_rx = managed
.subscribe("Network.requestWillBeSent")
.await
.map_err(|e| AppError {
message: format!("Failed to subscribe to Network.requestWillBeSent: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
let mut response_rx = managed
.subscribe("Network.responseReceived")
.await
.map_err(|e| AppError {
message: format!("Failed to subscribe to Network.responseReceived: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
let mut finished_rx = managed
.subscribe("Network.loadingFinished")
.await
.map_err(|e| AppError {
message: format!("Failed to subscribe to Network.loadingFinished: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
let mut failed_rx = managed
.subscribe("Network.loadingFailed")
.await
.map_err(|e| AppError {
message: format!("Failed to subscribe to Network.loadingFailed: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
let type_filter = resolve_type_filter(args.r#type.as_deref());
let url_filter = args.url.as_deref();
let method_filter = args.method.as_deref().map(str::to_uppercase);
let timeout_duration = args.timeout.map(Duration::from_millis);
let deadline = timeout_duration.map(|d| tokio::time::Instant::now() + d);
let mut in_flight: HashMap<String, InFlightRequest> = HashMap::new();
loop {
tokio::select! {
event = request_rx.recv() => {
match event {
Some(ev) => {
let request_id = ev.params["requestId"]
.as_str()
.unwrap_or("")
.to_string();
if request_id.is_empty() {
continue;
}
in_flight.insert(request_id, InFlightRequest {
method: ev.params["request"]["method"]
.as_str()
.unwrap_or("GET")
.to_string(),
url: ev.params["request"]["url"]
.as_str()
.unwrap_or("")
.to_string(),
resource_type: ev.params["type"]
.as_str()
.unwrap_or("other")
.to_lowercase(),
timestamp: ev.params["timestamp"].as_f64().unwrap_or(0.0),
wall_time: ev.params["wallTime"].as_f64().unwrap_or(0.0),
request_headers: ev.params["request"]["headers"].clone(),
response_headers: serde_json::Value::Null,
status: None,
});
}
None => {
return Err(AppError {
message: "CDP connection closed".to_string(),
code: ExitCode::ConnectionError,
custom_json: None,
});
}
}
}
event = response_rx.recv() => {
match event {
Some(ev) => {
let request_id = ev.params["requestId"]
.as_str()
.unwrap_or("");
if let Some(req) = in_flight.get_mut(request_id) {
#[allow(clippy::cast_possible_truncation)]
let status = ev.params["response"]["status"]
.as_u64()
.map(|s| s as u16);
req.status = status;
req.response_headers = ev.params["response"]["headers"].clone();
}
}
None => {
return Err(AppError {
message: "CDP connection closed".to_string(),
code: ExitCode::ConnectionError,
custom_json: None,
});
}
}
}
event = finished_rx.recv() => {
match event {
Some(ev) => {
let request_id = ev.params["requestId"]
.as_str()
.unwrap_or("");
let raw_size = ev.params["encodedDataLength"].as_u64();
let end_timestamp = ev.params["timestamp"].as_f64();
if let Some(req) = in_flight.remove(request_id) {
let size = resolve_size(raw_size, &req.response_headers);
emit_stream_event(
&req, size, end_timestamp, type_filter.as_deref(),
url_filter, method_filter.as_deref(), args.verbose,
);
}
}
None => {
return Err(AppError {
message: "CDP connection closed".to_string(),
code: ExitCode::ConnectionError,
custom_json: None,
});
}
}
}
event = failed_rx.recv() => {
match event {
Some(ev) => {
let request_id = ev.params["requestId"]
.as_str()
.unwrap_or("");
if let Some(req) = in_flight.remove(request_id) {
emit_stream_event(
&req, None, None, type_filter.as_deref(),
url_filter, method_filter.as_deref(), args.verbose,
);
}
}
None => {
return Err(AppError {
message: "CDP connection closed".to_string(),
code: ExitCode::ConnectionError,
custom_json: None,
});
}
}
}
() = async {
if let Some(d) = deadline {
tokio::time::sleep_until(d).await;
} else {
std::future::pending::<()>().await;
}
} => {
break;
}
_ = tokio::signal::ctrl_c() => {
break;
}
}
}
Ok(())
}
struct InFlightRequest {
method: String,
url: String,
resource_type: String,
timestamp: f64,
wall_time: f64,
request_headers: serde_json::Value,
response_headers: serde_json::Value,
status: Option<u16>,
}
fn emit_stream_event(
req: &InFlightRequest,
size: Option<u64>,
end_timestamp: Option<f64>,
type_filter: Option<&[String]>,
url_filter: Option<&str>,
method_filter: Option<&str>,
verbose: bool,
) {
if let Some(types) = type_filter
&& !types.iter().any(|t| t == &req.resource_type.to_lowercase())
{
return;
}
if let Some(pattern) = url_filter
&& !req.url.contains(pattern)
{
return;
}
if let Some(method) = method_filter
&& req.method.to_uppercase() != method
{
return;
}
let duration_ms = end_timestamp.map(|end| (end - req.timestamp) * 1000.0);
let event = NetworkStreamEvent {
method: req.method.clone(),
url: req.url.clone(),
status: req.status,
resource_type: req.resource_type.clone(),
size,
duration_ms,
timestamp: timestamp_to_iso(req.wall_time),
request_headers: if verbose {
Some(req.request_headers.clone())
} else {
None
},
response_headers: if verbose {
Some(req.response_headers.clone())
} else {
None
},
};
let json = serde_json::to_string(&event).unwrap_or_default();
println!("{json}");
let _ = std::io::stdout().flush();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn network_request_summary_serialization() {
let req = NetworkRequestSummary {
id: 0,
method: "GET".to_string(),
url: "https://example.com/api/data".to_string(),
status: Some(200),
resource_type: "xhr".to_string(),
size: Some(1234),
duration_ms: Some(45.2),
timestamp: "2026-02-14T12:00:00.000Z".to_string(),
};
let json: serde_json::Value = serde_json::to_value(&req).unwrap();
assert_eq!(json["id"], 0);
assert_eq!(json["method"], "GET");
assert_eq!(json["url"], "https://example.com/api/data");
assert_eq!(json["status"], 200);
assert_eq!(json["type"], "xhr");
assert_eq!(json["size"], 1234);
assert_eq!(json["timestamp"], "2026-02-14T12:00:00.000Z");
assert!(json.get("resource_type").is_none());
}
#[test]
fn network_request_summary_null_fields() {
let req = NetworkRequestSummary {
id: 1,
method: "GET".to_string(),
url: "https://example.com".to_string(),
status: None,
resource_type: "document".to_string(),
size: None,
duration_ms: None,
timestamp: String::new(),
};
let json: serde_json::Value = serde_json::to_value(&req).unwrap();
assert!(json["status"].is_null());
assert!(json["size"].is_null());
assert!(json["duration_ms"].is_null());
}
#[test]
fn network_request_detail_serialization() {
let detail = NetworkRequestDetail {
id: 1,
request: RequestInfo {
method: "POST".to_string(),
url: "https://example.com/api".to_string(),
headers: serde_json::json!({"Content-Type": "application/json"}),
body: Some("{\"key\":\"value\"}".to_string()),
},
response: ResponseInfo {
status: Some(200),
status_text: "OK".to_string(),
headers: serde_json::json!({"Content-Type": "application/json"}),
body: Some("{\"result\":\"ok\"}".to_string()),
binary: false,
truncated: false,
mime_type: Some("application/json".to_string()),
},
timing: TimingInfo {
dns_ms: 5.0,
connect_ms: 10.0,
tls_ms: 15.0,
ttfb_ms: 50.0,
download_ms: 20.0,
},
redirect_chain: vec![RedirectEntry {
url: "http://example.com/api".to_string(),
status: 301,
}],
resource_type: "xhr".to_string(),
size: Some(1234),
duration_ms: Some(100.2),
timestamp: "2026-02-14T12:00:00.000Z".to_string(),
};
let json: serde_json::Value = serde_json::to_value(&detail).unwrap();
assert_eq!(json["id"], 1);
assert_eq!(json["request"]["method"], "POST");
assert_eq!(json["response"]["status"], 200);
assert_eq!(json["response"]["binary"], false);
assert_eq!(json["response"]["truncated"], false);
assert_eq!(json["timing"]["dns_ms"], 5.0);
assert_eq!(json["timing"]["ttfb_ms"], 50.0);
assert_eq!(json["redirect_chain"][0]["status"], 301);
assert_eq!(json["type"], "xhr");
}
#[test]
fn stream_event_serialization() {
let event = NetworkStreamEvent {
method: "GET".to_string(),
url: "https://example.com/api".to_string(),
status: Some(200),
resource_type: "xhr".to_string(),
size: Some(1234),
duration_ms: Some(45.2),
timestamp: "2026-02-14T12:00:00.000Z".to_string(),
request_headers: None,
response_headers: None,
};
let json: serde_json::Value = serde_json::to_value(&event).unwrap();
assert_eq!(json["method"], "GET");
assert_eq!(json["status"], 200);
assert_eq!(json["type"], "xhr");
assert!(json.get("request_headers").is_none());
assert!(json.get("response_headers").is_none());
}
#[test]
fn stream_event_verbose_serialization() {
let event = NetworkStreamEvent {
method: "GET".to_string(),
url: "https://example.com/api".to_string(),
status: Some(200),
resource_type: "xhr".to_string(),
size: Some(1234),
duration_ms: Some(45.2),
timestamp: "2026-02-14T12:00:00.000Z".to_string(),
request_headers: Some(serde_json::json!({"Accept": "*/*"})),
response_headers: Some(serde_json::json!({"Content-Type": "application/json"})),
};
let json: serde_json::Value = serde_json::to_value(&event).unwrap();
assert_eq!(json["request_headers"]["Accept"], "*/*");
assert_eq!(json["response_headers"]["Content-Type"], "application/json");
}
fn make_request(
id: usize,
method: &str,
url: &str,
status: Option<u16>,
resource_type: &str,
) -> NetworkRequestSummary {
NetworkRequestSummary {
id,
method: method.to_string(),
url: url.to_string(),
status,
resource_type: resource_type.to_string(),
size: None,
duration_ms: None,
timestamp: String::new(),
}
}
#[test]
fn filter_by_type_single() {
let requests = vec![
make_request(0, "GET", "https://a.com", Some(200), "xhr"),
make_request(1, "GET", "https://b.com", Some(200), "document"),
make_request(2, "GET", "https://c.com", Some(200), "xhr"),
];
let filtered = filter_by_type(requests, &["xhr".to_string()]);
assert_eq!(filtered.len(), 2);
assert!(filtered.iter().all(|r| r.resource_type == "xhr"));
}
#[test]
fn filter_by_type_multiple() {
let requests = vec![
make_request(0, "GET", "https://a.com", Some(200), "xhr"),
make_request(1, "GET", "https://b.com", Some(200), "document"),
make_request(2, "GET", "https://c.com", Some(200), "fetch"),
];
let filtered = filter_by_type(requests, &["xhr".to_string(), "fetch".to_string()]);
assert_eq!(filtered.len(), 2);
}
#[test]
fn filter_by_url_substring() {
let requests = vec![
make_request(0, "GET", "https://api.example.com/data", Some(200), "xhr"),
make_request(
1,
"GET",
"https://cdn.example.com/image.png",
Some(200),
"image",
),
];
let filtered = filter_by_url(requests, "api.example.com");
assert_eq!(filtered.len(), 1);
assert!(filtered[0].url.contains("api.example.com"));
}
#[test]
fn filter_by_url_no_match() {
let requests = vec![make_request(
0,
"GET",
"https://example.com/page",
Some(200),
"document",
)];
let filtered = filter_by_url(requests, "api.nowhere.com");
assert!(filtered.is_empty());
}
#[test]
fn filter_by_status_exact() {
let requests = vec![
make_request(0, "GET", "https://a.com", Some(200), "document"),
make_request(1, "GET", "https://b.com", Some(404), "document"),
make_request(2, "GET", "https://c.com", Some(500), "document"),
];
let filter = parse_status_filter("404");
let filtered = filter_by_status(requests, &filter);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].status, Some(404));
}
#[test]
fn filter_by_status_wildcard() {
let requests = vec![
make_request(0, "GET", "https://a.com", Some(200), "document"),
make_request(1, "GET", "https://b.com", Some(400), "document"),
make_request(2, "GET", "https://c.com", Some(404), "document"),
make_request(3, "GET", "https://d.com", Some(500), "document"),
];
let filter = parse_status_filter("4xx");
let filtered = filter_by_status(requests, &filter);
assert_eq!(filtered.len(), 2);
assert!(filtered.iter().all(|r| {
let s = r.status.unwrap();
(400..500).contains(&s)
}));
}
#[test]
fn filter_by_status_none_skipped() {
let requests = vec![
make_request(0, "GET", "https://a.com", None, "document"),
make_request(1, "GET", "https://b.com", Some(200), "document"),
];
let filter = parse_status_filter("200");
let filtered = filter_by_status(requests, &filter);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].status, Some(200));
}
#[test]
fn filter_by_method_case_insensitive() {
let requests = vec![
make_request(0, "GET", "https://a.com", Some(200), "document"),
make_request(1, "POST", "https://b.com", Some(200), "xhr"),
make_request(2, "GET", "https://c.com", Some(200), "document"),
];
let filtered = filter_by_method(requests, "post");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].method, "POST");
}
fn make_requests(count: usize) -> Vec<NetworkRequestSummary> {
(0..count)
.map(|i| {
make_request(
i,
"GET",
&format!("https://example.com/{i}"),
Some(200),
"document",
)
})
.collect()
}
#[test]
fn paginate_page_0() {
let requests = make_requests(30);
let result = paginate(requests, 10, 0);
assert_eq!(result.len(), 10);
assert_eq!(result[0].id, 0);
assert_eq!(result[9].id, 9);
}
#[test]
fn paginate_page_1() {
let requests = make_requests(30);
let result = paginate(requests, 10, 1);
assert_eq!(result.len(), 10);
assert_eq!(result[0].id, 10);
assert_eq!(result[9].id, 19);
}
#[test]
fn paginate_beyond_available() {
let requests = make_requests(5);
let result = paginate(requests, 10, 1);
assert!(result.is_empty());
}
#[test]
fn paginate_partial_last_page() {
let requests = make_requests(15);
let result = paginate(requests, 10, 1);
assert_eq!(result.len(), 5);
assert_eq!(result[0].id, 10);
}
#[test]
fn parse_status_filter_exact_value() {
let filter = parse_status_filter("404");
assert!(filter.matches(404));
assert!(!filter.matches(200));
}
#[test]
fn parse_status_filter_wildcard_4xx() {
let filter = parse_status_filter("4xx");
assert!(filter.matches(400));
assert!(filter.matches(404));
assert!(filter.matches(499));
assert!(!filter.matches(500));
assert!(!filter.matches(200));
}
#[test]
fn parse_status_filter_wildcard_5xx() {
let filter = parse_status_filter("5xx");
assert!(filter.matches(500));
assert!(filter.matches(503));
assert!(!filter.matches(400));
}
#[test]
fn parse_status_filter_wildcard_2xx() {
let filter = parse_status_filter("2xx");
assert!(filter.matches(200));
assert!(filter.matches(201));
assert!(filter.matches(299));
assert!(!filter.matches(300));
}
#[test]
fn timestamp_to_iso_epoch_zero() {
assert_eq!(timestamp_to_iso(0.0), "1970-01-01T00:00:00.000Z");
}
#[test]
fn timestamp_to_iso_known_value() {
assert_eq!(
timestamp_to_iso(1_707_912_000.0),
"2024-02-14T12:00:00.000Z"
);
}
#[test]
fn timestamp_to_iso_with_milliseconds() {
assert_eq!(
timestamp_to_iso(1_707_912_000.123),
"2024-02-14T12:00:00.123Z"
);
}
#[test]
fn builder_to_summary_uses_wall_time_not_monotonic() {
let builder = NetworkRequestBuilder {
cdp_request_id: "1".to_string(),
assigned_id: 0,
method: "GET".to_string(),
url: "https://example.com".to_string(),
resource_type: "document".to_string(),
timestamp: 62090.044, wall_time: 1_707_912_000.123, request_headers: serde_json::Value::Null,
status: Some(200),
status_text: "OK".to_string(),
response_headers: serde_json::Value::Null,
mime_type: None,
encoded_data_length: None,
timing: None,
redirect_chain: Vec::new(),
completed: true,
failed: false,
error_text: None,
navigation_id: 0,
loading_finished_timestamp: Some(62090.544),
frame_id: None,
};
let summary = builder_to_summary(&builder);
assert!(
summary.timestamp.starts_with("2024-"),
"Expected wall-clock year 2024, got: {}",
summary.timestamp
);
assert_eq!(summary.timestamp, "2024-02-14T12:00:00.123Z");
assert!(
(summary.duration_ms.unwrap() - 500.0).abs() < 1.0,
"Duration should be ~500ms from monotonic diff"
);
}
fn raw_event(
event_type: NetworkEventType,
navigation_id: u32,
params: serde_json::Value,
) -> RawNetworkEvent {
RawNetworkEvent {
params,
event_type,
navigation_id,
}
}
fn request_event(request_id: &str, url: &str, resource_type: &str) -> serde_json::Value {
serde_json::json!({
"requestId": request_id,
"request": {
"method": "GET",
"url": url,
"headers": {"accept": "*/*"}
},
"type": resource_type,
"timestamp": 100.0,
"wallTime": 1_707_912_000.123,
"frameId": "frame-1"
})
}
fn response_event(request_id: &str) -> serde_json::Value {
serde_json::json!({
"requestId": request_id,
"response": {
"status": 200,
"statusText": "OK",
"headers": {"content-length": "456"},
"mimeType": "text/html",
"timing": {
"requestTime": 100.0,
"dnsStart": 0.0,
"dnsEnd": 1.0,
"connectStart": 1.0,
"connectEnd": 2.0,
"sslStart": -1.0,
"sslEnd": -1.0,
"sendEnd": 3.0,
"receiveHeadersEnd": 20.0
}
}
})
}
fn finished_event(request_id: &str) -> serde_json::Value {
serde_json::json!({
"requestId": request_id,
"encodedDataLength": 456,
"timestamp": 100.5
})
}
#[test]
fn correlate_raw_events_merges_out_of_order_response_and_finish() {
let raw_events = vec![
raw_event(
NetworkEventType::ResponseReceived,
1,
response_event("req-1"),
),
raw_event(
NetworkEventType::LoadingFinished,
1,
finished_event("req-1"),
),
raw_event(
NetworkEventType::RequestWillBeSent,
0,
request_event("req-1", "https://example.com/", "Document"),
),
];
let builders = correlate_raw_events(&raw_events, false, 3, Some(1_707_911_999.0));
assert_eq!(builders.len(), 1);
let summary = builder_to_summary(&builders[0]);
assert_eq!(summary.id, 0);
assert_eq!(summary.status, Some(200));
assert_eq!(summary.resource_type, "document");
assert_eq!(summary.size, Some(456));
assert!(
(summary.duration_ms.unwrap() - 500.0).abs() < 1.0,
"duration should be derived from request and finish timestamps"
);
assert_eq!(summary.timestamp, "2024-02-14T12:00:00.123Z");
}
#[test]
fn correlate_raw_events_assigns_ids_after_deterministic_sorting() {
let raw_events = vec![
raw_event(
NetworkEventType::RequestWillBeSent,
1,
serde_json::json!({
"requestId": "later",
"request": {"method": "GET", "url": "https://example.com/b", "headers": {}},
"type": "XHR",
"timestamp": 200.0,
"wallTime": 1_707_912_100.0
}),
),
raw_event(
NetworkEventType::RequestWillBeSent,
1,
serde_json::json!({
"requestId": "earlier",
"request": {"method": "GET", "url": "https://example.com/a", "headers": {}},
"type": "Document",
"timestamp": 100.0,
"wallTime": 1_707_912_000.0
}),
),
];
let builders = correlate_raw_events(&raw_events, false, 1, Some(1_707_911_999.0));
assert_eq!(builders[0].cdp_request_id, "earlier");
assert_eq!(builders[0].assigned_id, 0);
assert_eq!(builders[1].cdp_request_id, "later");
assert_eq!(builders[1].assigned_id, 1);
}
#[test]
fn fresh_snapshot_hit_and_miss_are_context_scoped() {
let context = NetworkTargetContext {
host: "127.0.0.1".to_string(),
port: 9222,
target_id: "target-1".to_string(),
};
let snapshot = NetworkSnapshot {
version: NETWORK_SNAPSHOT_VERSION,
context: context.clone(),
captured_at_epoch_secs: 1_000,
requests: vec![NetworkRequestBuilder {
cdp_request_id: "req-1".to_string(),
assigned_id: 7,
method: "GET".to_string(),
url: "https://example.com".to_string(),
resource_type: "document".to_string(),
timestamp: 100.0,
wall_time: 1_707_912_000.0,
request_headers: serde_json::Value::Null,
status: Some(200),
status_text: "OK".to_string(),
response_headers: serde_json::Value::Null,
mime_type: None,
encoded_data_length: Some(1),
timing: None,
redirect_chain: Vec::new(),
completed: true,
failed: false,
error_text: None,
navigation_id: 1,
loading_finished_timestamp: Some(100.1),
frame_id: None,
}],
};
assert!(matches!(
lookup_snapshot_request(Some(snapshot), &context, 7, 1_010),
SnapshotLookup::Hit(_)
));
let miss_snapshot = NetworkSnapshot {
version: NETWORK_SNAPSHOT_VERSION,
context: context.clone(),
captured_at_epoch_secs: 1_000,
requests: Vec::new(),
};
assert!(matches!(
lookup_snapshot_request(Some(miss_snapshot), &context, 7, 1_010),
SnapshotLookup::FreshMiss
));
let stale_snapshot = NetworkSnapshot {
version: NETWORK_SNAPSHOT_VERSION,
context,
captured_at_epoch_secs: 1_000,
requests: Vec::new(),
};
assert!(matches!(
lookup_snapshot_request(Some(stale_snapshot), &stale_snapshot_context(), 7, 2_000),
SnapshotLookup::MissingOrStale
));
}
fn stale_snapshot_context() -> NetworkTargetContext {
NetworkTargetContext {
host: "127.0.0.1".to_string(),
port: 9222,
target_id: "target-1".to_string(),
}
}
#[test]
fn binary_mime_detection() {
assert!(is_binary_mime("image/png"));
assert!(is_binary_mime("image/jpeg"));
assert!(is_binary_mime("audio/mpeg"));
assert!(is_binary_mime("video/mp4"));
assert!(is_binary_mime("application/octet-stream"));
assert!(is_binary_mime("application/pdf"));
assert!(is_binary_mime("font/woff2"));
assert!(is_binary_mime("application/wasm"));
assert!(!is_binary_mime("text/html"));
assert!(!is_binary_mime("application/json"));
assert!(!is_binary_mime("text/css"));
}
#[test]
fn extract_timing_full() {
let timing = serde_json::json!({
"dnsStart": 0.0,
"dnsEnd": 5.0,
"connectStart": 5.0,
"connectEnd": 15.0,
"sslStart": 10.0,
"sslEnd": 15.0,
"sendEnd": 16.0,
"receiveHeadersEnd": 66.0
});
let ti = extract_timing(&timing);
assert!((ti.dns_ms - 5.0).abs() < f64::EPSILON);
assert!((ti.connect_ms - 10.0).abs() < f64::EPSILON);
assert!((ti.tls_ms - 5.0).abs() < f64::EPSILON);
assert!((ti.ttfb_ms - 50.0).abs() < f64::EPSILON);
}
#[test]
fn extract_timing_missing_fields() {
let timing = serde_json::json!({});
let ti = extract_timing(&timing);
assert!((ti.dns_ms).abs() < f64::EPSILON);
assert!((ti.connect_ms).abs() < f64::EPSILON);
assert!((ti.tls_ms).abs() < f64::EPSILON);
assert!((ti.ttfb_ms).abs() < f64::EPSILON);
}
#[test]
fn body_under_limit_not_truncated() {
let body = "a".repeat(100);
assert!(body.len() <= MAX_INLINE_BODY_SIZE);
}
#[test]
fn body_over_limit_truncated() {
let body = "a".repeat(MAX_INLINE_BODY_SIZE + 1000);
let truncated = &body[..MAX_INLINE_BODY_SIZE];
assert_eq!(truncated.len(), MAX_INLINE_BODY_SIZE);
}
#[test]
fn resolve_type_filter_none() {
assert!(resolve_type_filter(None).is_none());
}
#[test]
fn resolve_type_filter_single() {
let result = resolve_type_filter(Some("xhr"));
let types = result.unwrap();
assert_eq!(types, vec!["xhr"]);
}
#[test]
fn resolve_type_filter_multiple() {
let result = resolve_type_filter(Some("xhr,fetch,document"));
let types = result.unwrap();
assert_eq!(types.len(), 3);
assert!(types.contains(&"xhr".to_string()));
assert!(types.contains(&"fetch".to_string()));
assert!(types.contains(&"document".to_string()));
}
#[test]
fn plain_text_list_empty() {
print_list_plain(&[]);
}
#[test]
fn plain_text_list_requests() {
let requests = vec![
make_request(0, "GET", "https://example.com", Some(200), "document"),
make_request(1, "POST", "https://api.example.com", Some(404), "xhr"),
];
print_list_plain(&requests);
}
#[test]
fn plain_text_detail() {
let detail = NetworkRequestDetail {
id: 0,
request: RequestInfo {
method: "GET".to_string(),
url: "https://example.com".to_string(),
headers: serde_json::json!({}),
body: None,
},
response: ResponseInfo {
status: Some(200),
status_text: "OK".to_string(),
headers: serde_json::json!({}),
body: Some("hello".to_string()),
binary: false,
truncated: false,
mime_type: Some("text/html".to_string()),
},
timing: TimingInfo {
dns_ms: 1.0,
connect_ms: 2.0,
tls_ms: 3.0,
ttfb_ms: 4.0,
download_ms: 5.0,
},
redirect_chain: vec![],
resource_type: "document".to_string(),
size: Some(5),
duration_ms: Some(15.0),
timestamp: "2026-02-14T12:00:00.000Z".to_string(),
};
print_detail_plain(&detail);
}
#[test]
fn resolve_size_uses_encoded_data_length_when_nonzero() {
let headers = serde_json::json!({"content-length": "5000"});
assert_eq!(resolve_size(Some(1234), &headers), Some(1234));
}
#[test]
fn resolve_size_falls_back_to_content_length_when_zero() {
let headers = serde_json::json!({"content-length": "5000"});
assert_eq!(resolve_size(Some(0), &headers), Some(5000));
}
#[test]
fn resolve_size_falls_back_to_content_length_when_none() {
let headers = serde_json::json!({"content-length": "3000"});
assert_eq!(resolve_size(None, &headers), Some(3000));
}
#[test]
fn resolve_size_case_insensitive_header() {
let headers = serde_json::json!({"Content-Length": "7777"});
assert_eq!(resolve_size(Some(0), &headers), Some(7777));
}
#[test]
fn resolve_size_returns_none_when_both_absent() {
let headers = serde_json::json!({});
assert_eq!(resolve_size(None, &headers), None);
}
#[test]
fn resolve_size_returns_none_for_malformed_content_length() {
let headers = serde_json::json!({"content-length": "not-a-number"});
assert_eq!(resolve_size(Some(0), &headers), None);
}
#[test]
fn resolve_size_returns_none_when_headers_null() {
assert_eq!(resolve_size(Some(0), &serde_json::Value::Null), None);
}
#[test]
fn resolve_size_skips_zero_content_length() {
let headers = serde_json::json!({"content-length": "0"});
assert_eq!(resolve_size(Some(0), &headers), None);
}
#[test]
fn resolve_size_builder_to_summary_integration() {
let builder = NetworkRequestBuilder {
cdp_request_id: "1".to_string(),
assigned_id: 0,
method: "GET".to_string(),
url: "https://example.com".to_string(),
resource_type: "document".to_string(),
timestamp: 62090.044,
wall_time: 1_707_912_000.123,
request_headers: serde_json::Value::Null,
status: Some(200),
status_text: "OK".to_string(),
response_headers: serde_json::json!({"content-length": "377301"}),
mime_type: None,
encoded_data_length: Some(0),
timing: None,
redirect_chain: Vec::new(),
completed: true,
failed: false,
error_text: None,
navigation_id: 0,
loading_finished_timestamp: Some(62090.544),
frame_id: None,
};
let summary = builder_to_summary(&builder);
assert_eq!(
summary.size,
Some(377_301),
"Size should fall back to content-length when encodedDataLength is 0"
);
}
#[test]
fn redirect_entry_serialization() {
let entry = RedirectEntry {
url: "http://example.com".to_string(),
status: 301,
};
let json: serde_json::Value = serde_json::to_value(&entry).unwrap();
assert_eq!(json["url"], "http://example.com");
assert_eq!(json["status"], 301);
}
#[test]
fn extract_domain_https() {
assert_eq!(
extract_domain("https://api.example.com/path"),
Some("api.example.com".to_string())
);
}
#[test]
fn extract_domain_http() {
assert_eq!(
extract_domain("http://example.com:8080/path"),
Some("example.com".to_string())
);
}
#[test]
fn extract_domain_no_scheme() {
assert_eq!(
extract_domain("example.com/path"),
Some("example.com".to_string())
);
}
}