feat: add HAR 1.2 network capture commands (#864)

* feat: enhance HAR entries with timings, cookies, postData, and protocol normalisation

Extends #874's HarEntry struct and CDP handlers with richer capture:

- wall_time (f64) replaces pre-formatted started_date_time, preserving
  sub-second precision from CDP wallTime for accurate RFC 3339 output
- request_headers / response_headers changed from Value to Vec<(String,String)>
  for typed access without re-parsing JSON objects
- post_data captured from Network.requestWillBeSent for HAR postData
- cdp_timing and loading_finished_timestamp captured from responseReceived
  and loadingFinished respectively, enabling har_compute_timings to produce
  accurate blocked/dns/connect/ssl/send/wait/receive phases
- har_cdp_protocol_to_http_version normalises CDP protocol strings
  (h2 -> HTTP/2.0, h3 -> HTTP/3.0, etc.)
- Request cookies parsed from Cookie header; response cookies from Set-Cookie
  with ';'-first split to correctly strip Path/HttpOnly attributes before '='
- har_wall_time_to_rfc3339 replaces har_started_date_time, using the time
  crate directly on the f64 epoch value

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: apply cargo fmt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cauê Felchar
2026-03-17 15:27:27 -05:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 8fc1d000f7
commit b8a5fc7101
+471 -186
View File
@@ -42,19 +42,31 @@ pub struct PendingConfirmation {
/// Captured request/response metadata used to export HAR 1.2 files. /// Captured request/response metadata used to export HAR 1.2 files.
pub struct HarEntry { pub struct HarEntry {
pub request_id: String,
/// Seconds since Unix epoch (CDP `wallTime`), with sub-second precision.
pub wall_time: f64,
// Request fields
pub method: String, pub method: String,
pub url: String, pub url: String,
pub status: Option<i64>, pub request_headers: Vec<(String, String)>,
pub status_text: Option<String>, pub post_data: Option<String>,
pub mime_type: Option<String>,
pub request_id: String,
pub request_headers: Value,
pub request_body_size: i64, pub request_body_size: i64,
pub response_headers: Value,
pub started_date_time: String,
pub resource_type: String, pub resource_type: String,
pub http_version: Option<String>, // Response fields — populated by `Network.responseReceived`
pub response_body_size: Option<i64>, pub status: Option<i64>,
pub status_text: String,
/// Normalised from CDP `response.protocol` (e.g. `"h2"` → `"HTTP/2.0"`).
pub http_version: String,
pub response_headers: Vec<(String, String)>,
pub mime_type: String,
pub redirect_url: String,
/// Updated by `Network.loadingFinished` for final accuracy.
pub response_body_size: i64,
/// Raw CDP `ResourceTiming` object from `Network.responseReceived`.
pub cdp_timing: Option<Value>,
/// Monotonic timestamp (seconds) from `Network.loadingFinished`; used to
/// compute the `receive` timing phase.
pub loading_finished_timestamp: Option<f64>,
} }
pub struct RouteEntry { pub struct RouteEntry {
@@ -349,36 +361,43 @@ impl DaemonState {
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
if self.har_recording { if self.har_recording {
let headers = let wall_time = event
request.get("headers").cloned().unwrap_or(json!({})); .params
let request_body_size = request .get("wallTime")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let request_headers =
har_extract_headers(request.get("headers"));
let post_data = request
.get("postData") .get("postData")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|body| body.len() as i64) .map(String::from);
.unwrap_or(0); let request_body_size =
post_data.as_ref().map(|s| s.len() as i64).unwrap_or(0);
let resource_type = event let resource_type = event
.params .params
.get("type") .get("type")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("Other") .unwrap_or("Other")
.to_string(); .to_string();
let started_date_time = har_started_date_time(
event.params.get("wallTime").and_then(|v| v.as_f64()),
);
self.har_entries.push(HarEntry { self.har_entries.push(HarEntry {
request_id,
wall_time,
method: method.clone(), method: method.clone(),
url: url.clone(), url: url.clone(),
status: None, request_headers,
status_text: None, post_data,
mime_type: None,
request_id,
request_headers: headers,
request_body_size, request_body_size,
response_headers: json!({}),
started_date_time,
resource_type, resource_type,
http_version: None, status: None,
response_body_size: None, status_text: String::new(),
http_version: "HTTP/1.1".to_string(),
response_headers: Vec::new(),
mime_type: String::new(),
redirect_url: String::new(),
response_body_size: -1,
cdp_timing: None,
loading_finished_timestamp: None,
}); });
} }
if self.request_tracking { if self.request_tracking {
@@ -412,23 +431,32 @@ impl DaemonState {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
let status = response.get("status").and_then(|v| v.as_i64()); let status = response.get("status").and_then(|v| v.as_i64());
let mime_type = response
.get("mimeType")
.and_then(|v| v.as_str())
.map(String::from);
let status_text = response let status_text = response
.get("statusText") .get("statusText")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from); .unwrap_or("")
let headers = response.get("headers").cloned().unwrap_or(json!({})); .to_string();
let mime_type = response
.get("mimeType")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let http_version = response let http_version = response
.get("protocol") .get("protocol")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from); .map(har_cdp_protocol_to_http_version)
let response_body_size = response .unwrap_or_else(|| "HTTP/1.1".to_string());
let response_headers = har_extract_headers(response.get("headers"));
let redirect_url = response_headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("location"))
.map(|(_, v)| v.clone())
.unwrap_or_default();
let encoded_data_length = response
.get("encodedDataLength") .get("encodedDataLength")
.and_then(|v| v.as_f64()) .and_then(|v| v.as_i64())
.map(|size| size.max(0.0) as i64); .unwrap_or(-1);
let cdp_timing = response.get("timing").cloned();
if let Some(entry) = self if let Some(entry) = self
.har_entries .har_entries
.iter_mut() .iter_mut()
@@ -438,11 +466,11 @@ impl DaemonState {
entry.status = status; entry.status = status;
entry.status_text = status_text; entry.status_text = status_text;
entry.mime_type = mime_type; entry.mime_type = mime_type;
entry.response_headers = headers;
entry.http_version = http_version; entry.http_version = http_version;
if response_body_size.is_some() { entry.response_headers = response_headers;
entry.response_body_size = response_body_size; entry.redirect_url = redirect_url;
} entry.response_body_size = encoded_data_length;
entry.cdp_timing = cdp_timing;
} }
} }
} }
@@ -452,19 +480,22 @@ impl DaemonState {
.get("requestId") .get("requestId")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
let timestamp = event.params.get("timestamp").and_then(|v| v.as_f64());
let encoded_data_length = event let encoded_data_length = event
.params .params
.get("encodedDataLength") .get("encodedDataLength")
.and_then(|v| v.as_f64()) .and_then(|v| v.as_i64());
.map(|size| size.max(0.0) as i64);
if let Some(entry) = self if let Some(entry) = self
.har_entries .har_entries
.iter_mut() .iter_mut()
.rev() .rev()
.find(|e| e.request_id == request_id) .find(|e| e.request_id == request_id)
{ {
if encoded_data_length.is_some() { if let Some(ts) = timestamp {
entry.response_body_size = encoded_data_length; entry.loading_finished_timestamp = Some(ts);
}
if let Some(len) = encoded_data_length {
entry.response_body_size = len;
} }
} }
} }
@@ -4577,7 +4608,7 @@ async fn handle_har_stop(cmd: &Value, state: &mut DaemonState) -> Result<Value,
state.har_recording = false; state.har_recording = false;
let entries: Vec<Value> = state.har_entries.drain(..).map(har_entry_json).collect(); let entries: Vec<Value> = state.har_entries.drain(..).map(har_entry_to_json).collect();
let request_count = entries.len(); let request_count = entries.len();
let browser = har_browser_metadata(state).await; let browser = har_browser_metadata(state).await;
@@ -4601,114 +4632,271 @@ async fn handle_har_stop(cmd: &Value, state: &mut DaemonState) -> Result<Value,
Ok(json!({ "path": path, "requestCount": request_count })) Ok(json!({ "path": path, "requestCount": request_count }))
} }
fn har_entry_json(entry: HarEntry) -> Value { // ---------------------------------------------------------------------------
let query_string = har_query_string(&entry.url); // HAR serialization helpers
let request_headers = har_headers(&entry.request_headers); // ---------------------------------------------------------------------------
let response_headers = har_headers(&entry.response_headers);
let http_version = entry.http_version.unwrap_or_default(); /// Convert a `HarEntry` (collected from CDP events) into a HAR 1.2 entry object.
let body_size = entry.response_body_size.unwrap_or(-1); fn har_entry_to_json(e: HarEntry) -> Value {
let content_size = entry.response_body_size.unwrap_or(0); let started_date_time = har_wall_time_to_rfc3339(e.wall_time);
let request_cookies = e
.request_headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("cookie"))
.map(|(_, v)| har_parse_request_cookies(v))
.unwrap_or_default();
let query_string = har_parse_query_string(&e.url);
let req_headers: Vec<Value> = e
.request_headers
.iter()
.map(|(k, v)| json!({ "name": k, "value": v }))
.collect();
let resp_cookies: Vec<Value> = e
.response_headers
.iter()
.filter(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
.map(|(_, v)| {
// Split on ';' first to discard attributes (Path, HttpOnly, etc.),
// then split on '=' once to separate name from value.
let name_value = v.split(';').next().unwrap_or("");
let (name, value) = name_value.split_once('=').unwrap_or((name_value, ""));
json!({ "name": name.trim(), "value": value.trim() })
})
.collect();
let resp_headers: Vec<Value> = e
.response_headers
.iter()
.map(|(k, v)| json!({ "name": k, "value": v }))
.collect();
let (timings, total_time) =
har_compute_timings(e.cdp_timing.as_ref(), e.loading_finished_timestamp);
let mime_type = if e.mime_type.is_empty() {
"application/octet-stream".to_string()
} else {
e.mime_type
};
let post_content_type = e
.request_headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
.map(|(_, v)| v.as_str())
.unwrap_or("text/plain")
.to_string();
let mut request = json!({
"method": e.method,
"url": e.url,
"httpVersion": e.http_version,
"cookies": request_cookies,
"headers": req_headers,
"queryString": query_string,
"headersSize": -1,
"bodySize": e.request_body_size,
});
if let Some(body) = e.post_data {
request["postData"] = json!({ "mimeType": post_content_type, "text": body });
}
json!({ json!({
"startedDateTime": entry.started_date_time, "startedDateTime": started_date_time,
"time": 0, "time": total_time,
"request": { "request": request,
"method": entry.method,
"url": entry.url,
"httpVersion": http_version.clone(),
"cookies": [],
"headers": request_headers,
"queryString": query_string,
"headersSize": -1,
"bodySize": entry.request_body_size,
},
"response": { "response": {
"status": entry.status.unwrap_or(0), "status": e.status.unwrap_or(0),
"statusText": entry.status_text.unwrap_or_default(), "statusText": e.status_text,
"httpVersion": http_version, "httpVersion": e.http_version,
"cookies": [], "cookies": resp_cookies,
"headers": response_headers, "headers": resp_headers,
"content": { "content": {
"size": content_size, "size": e.response_body_size,
"mimeType": entry.mime_type.unwrap_or_default(), "mimeType": mime_type,
}, },
"redirectURL": har_redirect_url(&entry.response_headers), "redirectURL": e.redirect_url,
"headersSize": -1, "headersSize": -1,
"bodySize": body_size, "bodySize": e.response_body_size,
}, },
"cache": {}, "cache": {},
"timings": { "timings": timings,
"blocked": -1, "_resourceType": e.resource_type,
"dns": -1,
"connect": -1,
"send": -1,
"wait": -1,
"receive": -1,
"ssl": -1,
},
"_resourceType": entry.resource_type,
}) })
} }
/// Convert the raw CDP header object into HAR header items. /// Convert a CDP headers object (`{ "Name": "value", ... }`) into a flat
fn har_headers(headers: &Value) -> Vec<Value> { /// `Vec<(name, value)>` preserving insertion order.
let Some(obj) = headers.as_object() else { fn har_extract_headers(headers_val: Option<&Value>) -> Vec<(String, String)> {
return Vec::new(); headers_val
}; .and_then(|v| v.as_object())
.map(|obj| {
let mut entries: Vec<(&String, &Value)> = obj.iter().collect(); obj.iter()
entries.sort_by(|(a, _), (b, _)| a.cmp(b)); .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
entries .collect()
.into_iter()
.map(|(name, value)| {
json!({
"name": name,
"value": har_header_value(value),
})
}) })
.collect() .unwrap_or_default()
} }
fn har_header_value(value: &Value) -> String { /// Map a CDP `response.protocol` value to an HTTP-version string as required
match value { /// by the HAR spec (e.g. `"h2"` → `"HTTP/2.0"`).
Value::Null => String::new(), fn har_cdp_protocol_to_http_version(protocol: &str) -> String {
Value::String(s) => s.clone(), match protocol.to_ascii_lowercase().as_str() {
Value::Number(n) => n.to_string(), "h2" => "HTTP/2.0".to_string(),
Value::Bool(b) => b.to_string(), "h3" => "HTTP/3.0".to_string(),
Value::Array(values) => values "http/1.0" => "HTTP/1.0".to_string(),
.iter() _ => "HTTP/1.1".to_string(),
.map(har_header_value)
.collect::<Vec<_>>()
.join(", "),
Value::Object(_) => value.to_string(),
} }
} }
fn har_query_string(raw_url: &str) -> Vec<Value> { /// Parse query-string parameters from a URL into a HAR `queryString` array.
let Ok(url) = url::Url::parse(raw_url) else { fn har_parse_query_string(url_str: &str) -> Vec<Value> {
return Vec::new(); url::Url::parse(url_str)
}; .map(|u| {
u.query_pairs()
.map(|(k, v)| json!({ "name": k.as_ref(), "value": v.as_ref() }))
.collect()
})
.unwrap_or_default()
}
url.query_pairs() /// Parse a `Cookie: name1=val1; name2=val2` header value into HAR cookie objects.
.map(|(name, value)| { fn har_parse_request_cookies(cookie_header: &str) -> Vec<Value> {
json!({ cookie_header
"name": name.into_owned(), .split(';')
"value": value.into_owned(), .filter_map(|pair| {
}) let pair = pair.trim();
if pair.is_empty() {
return None;
}
let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
Some(json!({ "name": name.trim(), "value": value.trim() }))
}) })
.collect() .collect()
} }
fn har_redirect_url(headers: &Value) -> String { /// Compute HAR `timings` and total `time` (ms) from a CDP `ResourceTiming`
har_header_lookup(headers, "location").unwrap_or_default() /// object and the optional `Network.loadingFinished` monotonic timestamp.
///
/// CDP timing values are milliseconds relative to `requestTime` (seconds since
/// browser start). A value of `-1` means the phase did not occur.
fn har_compute_timings(
cdp_timing: Option<&Value>,
loading_finished_ts: Option<f64>,
) -> (Value, f64) {
let Some(t) = cdp_timing else {
return (json!({ "send": 0, "wait": 0, "receive": 0 }), 0.0);
};
let get = |key: &str| t.get(key).and_then(|v| v.as_f64()).unwrap_or(-1.0);
let request_time = get("requestTime");
let dns_start = get("dnsStart");
let dns_end = get("dnsEnd");
let connect_start = get("connectStart");
let connect_end = get("connectEnd");
let ssl_start = get("sslStart");
let ssl_end = get("sslEnd");
let send_start = get("sendStart");
let send_end = get("sendEnd");
let recv_headers_start = get("receiveHeadersStart");
let recv_headers_end = get("receiveHeadersEnd");
let dns = if dns_start >= 0.0 && dns_end >= 0.0 {
dns_end - dns_start
} else {
-1.0
};
let connect = if connect_start >= 0.0 && connect_end >= 0.0 {
connect_end - connect_start
} else {
-1.0
};
let ssl = if ssl_start >= 0.0 && ssl_end >= 0.0 {
ssl_end - ssl_start
} else {
-1.0
};
let send = (send_end - send_start).max(0.0);
// wait: end of sending → first byte of response headers.
let wait_end = if recv_headers_start >= 0.0 {
recv_headers_start
} else {
recv_headers_end
};
let wait = if send_end >= 0.0 && wait_end >= send_end {
wait_end - send_end
} else {
0.0
};
// receive: first response byte → loading complete.
// requestTime (seconds) + recv_headers_end (ms) / 1000 = absolute headers-end timestamp.
let receive = loading_finished_ts
.filter(|_| request_time >= 0.0 && recv_headers_end >= 0.0)
.map(|lf_ts| {
let recv_start_abs = request_time + recv_headers_end / 1000.0;
((lf_ts - recv_start_abs) * 1000.0).max(0.0)
})
.unwrap_or(0.0);
let blocked = if dns_start > 0.0 {
dns_start
} else if connect_start > 0.0 {
connect_start
} else if send_start > 0.0 {
send_start
} else {
-1.0
};
let total: f64 = [
if blocked > 0.0 { blocked } else { 0.0 },
if dns >= 0.0 { dns } else { 0.0 },
if connect >= 0.0 { connect } else { 0.0 },
send,
wait,
receive,
]
.iter()
.sum();
let mut timings = json!({ "send": send, "wait": wait, "receive": receive });
if blocked > 0.0 {
timings["blocked"] = json!(blocked);
}
if dns >= 0.0 {
timings["dns"] = json!(dns);
}
if connect >= 0.0 {
timings["connect"] = json!(connect);
}
if ssl >= 0.0 {
timings["ssl"] = json!(ssl);
}
(timings, total)
} }
fn har_header_lookup(headers: &Value, expected_name: &str) -> Option<String> { /// Format a Unix epoch timestamp (seconds, fractional) as RFC 3339 using the
headers.as_object().and_then(|obj| { /// `time` crate, e.g. `"2024-03-17T10:30:00.456Z"`.
obj.iter() fn har_wall_time_to_rfc3339(wall_time: f64) -> String {
.find(|(name, _)| name.eq_ignore_ascii_case(expected_name)) if wall_time > 0.0 {
.map(|(_, value)| har_header_value(value)) let nanos = (wall_time * 1_000_000_000.0).round() as i128;
}) if let Ok(dt) = OffsetDateTime::from_unix_timestamp_nanos(nanos) {
if let Ok(s) = dt.format(&Rfc3339) {
return s;
}
}
}
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
} }
fn har_output_path(explicit_path: Option<&str>) -> String { fn har_output_path(explicit_path: Option<&str>) -> String {
@@ -4739,23 +4927,6 @@ fn unix_timestamp_millis() -> u128 {
.as_millis() .as_millis()
} }
fn har_started_date_time(wall_time: Option<f64>) -> String {
wall_time
.and_then(offset_date_time_from_epoch_seconds)
.unwrap_or_else(OffsetDateTime::now_utc)
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}
fn offset_date_time_from_epoch_seconds(seconds: f64) -> Option<OffsetDateTime> {
if !seconds.is_finite() || seconds < 0.0 {
return None;
}
let nanos = (seconds * 1_000_000_000.0).round() as i128;
OffsetDateTime::from_unix_timestamp_nanos(nanos).ok()
}
async fn har_browser_metadata(state: &DaemonState) -> Option<Value> { async fn har_browser_metadata(state: &DaemonState) -> Option<Value> {
let mgr = state.browser.as_ref()?; let mgr = state.browser.as_ref()?;
if !mgr.is_connection_alive().await { if !mgr.is_connection_alive().await {
@@ -5714,47 +5885,153 @@ mod tests {
} }
#[test] #[test]
fn test_har_entry_json_enriches_request_and_response() { fn test_har_entry_to_json_enriches_request_and_response() {
// wall_time: 2026-03-15T12:00:00Z = 1_773_576_000
let entry = HarEntry { let entry = HarEntry {
request_id: "req-1".to_string(),
wall_time: 1773576000.0,
method: "POST".to_string(), method: "POST".to_string(),
url: "https://example.com/api?foo=bar&baz=qux".to_string(), url: "https://example.com/api?foo=bar&baz=qux".to_string(),
status: Some(201), request_headers: vec![
status_text: Some("Created".to_string()), ("Accept".to_string(), "application/json".to_string()),
mime_type: Some("application/json".to_string()), ("Content-Type".to_string(), "application/json".to_string()),
request_id: "req-1".to_string(), ("Cookie".to_string(), "session=abc; theme=dark".to_string()),
request_headers: json!({ ],
"Accept": "application/json", post_data: Some(r#"{"x":1}"#.to_string()),
"X-Trace": ["a", "b"], request_body_size: 7,
}),
request_body_size: 17,
response_headers: json!({
"Content-Type": "application/json",
"Location": "https://example.com/api/1",
}),
started_date_time: "2026-03-15T12:00:00Z".to_string(),
resource_type: "XHR".to_string(), resource_type: "XHR".to_string(),
http_version: Some("h2".to_string()), status: Some(201),
response_body_size: Some(42), status_text: "Created".to_string(),
http_version: "HTTP/2.0".to_string(),
response_headers: vec![
("content-type".to_string(), "application/json".to_string()),
(
"location".to_string(),
"https://example.com/api/1".to_string(),
),
(
"set-cookie".to_string(),
"token=xyz; Path=/; HttpOnly".to_string(),
),
],
mime_type: "application/json".to_string(),
redirect_url: "https://example.com/api/1".to_string(),
response_body_size: 42,
cdp_timing: None,
loading_finished_timestamp: None,
}; };
let har = har_entry_json(entry); let har = har_entry_to_json(entry);
assert_eq!(har["startedDateTime"], "2026-03-15T12:00:00Z"); assert_eq!(har["startedDateTime"], "2026-03-15T12:00:00Z");
assert_eq!(har["request"]["method"], "POST"); assert_eq!(har["request"]["method"], "POST");
assert_eq!(har["request"]["httpVersion"], "h2"); assert_eq!(har["request"]["httpVersion"], "HTTP/2.0");
assert_eq!(har["request"]["queryString"][0]["name"], "foo"); assert_eq!(har["request"]["queryString"][0]["name"], "foo");
assert_eq!(har["request"]["queryString"][0]["value"], "bar"); assert_eq!(har["request"]["queryString"][0]["value"], "bar");
assert_eq!(har["request"]["bodySize"], 17); assert_eq!(har["request"]["bodySize"], 7);
assert_eq!(har["request"]["postData"]["mimeType"], "application/json");
assert_eq!(har["request"]["postData"]["text"], r#"{"x":1}"#);
assert_eq!(har["request"]["cookies"][0]["name"], "session");
assert_eq!(har["request"]["cookies"][0]["value"], "abc");
assert_eq!(har["request"]["cookies"][1]["name"], "theme");
assert_eq!(har["request"]["cookies"][1]["value"], "dark");
assert_eq!(har["response"]["status"], 201); assert_eq!(har["response"]["status"], 201);
assert_eq!(har["response"]["statusText"], "Created"); assert_eq!(har["response"]["statusText"], "Created");
assert_eq!(har["response"]["content"]["mimeType"], "application/json"); assert_eq!(har["response"]["content"]["mimeType"], "application/json");
assert_eq!(har["response"]["content"]["size"], 42); assert_eq!(har["response"]["content"]["size"], 42);
assert_eq!(har["response"]["redirectURL"], "https://example.com/api/1"); assert_eq!(har["response"]["redirectURL"], "https://example.com/api/1");
assert_eq!(har["response"]["cookies"][0]["name"], "token");
assert_eq!(har["response"]["cookies"][0]["value"], "xyz");
assert_eq!(har["_resourceType"], "XHR"); assert_eq!(har["_resourceType"], "XHR");
} }
#[test] #[test]
fn test_har_started_date_time_uses_rfc3339() { fn test_har_wall_time_to_rfc3339_epoch() {
assert_eq!(har_started_date_time(Some(0.0)), "1970-01-01T00:00:00Z"); // Known timestamp: 2026-03-15T12:00:00Z = 1_773_576_000
let result = har_wall_time_to_rfc3339(1773576000.0);
assert!(result.starts_with("2026-03-15T12:00:00"));
}
#[test]
fn test_har_wall_time_to_rfc3339_fractional_seconds() {
let result = har_wall_time_to_rfc3339(1773576000.456);
assert!(result.contains(".456") || result.contains("456"));
}
#[test]
fn test_har_cdp_protocol_to_http_version() {
assert_eq!(har_cdp_protocol_to_http_version("h2"), "HTTP/2.0");
assert_eq!(har_cdp_protocol_to_http_version("h3"), "HTTP/3.0");
assert_eq!(har_cdp_protocol_to_http_version("http/1.0"), "HTTP/1.0");
assert_eq!(har_cdp_protocol_to_http_version("http/1.1"), "HTTP/1.1");
assert_eq!(har_cdp_protocol_to_http_version("unknown"), "HTTP/1.1");
}
#[test]
fn test_har_parse_request_cookies() {
let cookies = har_parse_request_cookies("session=abc; theme=dark; empty=");
assert_eq!(cookies.len(), 3);
assert_eq!(cookies[0]["name"], "session");
assert_eq!(cookies[0]["value"], "abc");
assert_eq!(cookies[1]["name"], "theme");
assert_eq!(cookies[1]["value"], "dark");
assert_eq!(cookies[2]["name"], "empty");
assert_eq!(cookies[2]["value"], "");
}
#[test]
fn test_har_set_cookie_strips_attributes_before_equal_split() {
let entry = HarEntry {
request_id: "r".to_string(),
wall_time: 1773576000.0,
method: "GET".to_string(),
url: "https://example.com/".to_string(),
request_headers: vec![],
post_data: None,
request_body_size: 0,
resource_type: "Document".to_string(),
status: Some(200),
status_text: "OK".to_string(),
http_version: "HTTP/1.1".to_string(),
response_headers: vec![(
"set-cookie".to_string(),
"token=abc; Path=/; HttpOnly".to_string(),
)],
mime_type: "text/html".to_string(),
redirect_url: String::new(),
response_body_size: 0,
cdp_timing: None,
loading_finished_timestamp: None,
};
let har = har_entry_to_json(entry);
assert_eq!(har["response"]["cookies"][0]["name"], "token");
assert_eq!(har["response"]["cookies"][0]["value"], "abc");
}
#[test]
fn test_har_compute_timings_no_cdp_timing() {
let (timings, total) = har_compute_timings(None, None);
assert_eq!(timings["send"], 0);
assert_eq!(timings["wait"], 0);
assert_eq!(timings["receive"], 0);
assert_eq!(total, 0.0);
}
#[test]
fn test_har_compute_timings_with_cdp_timing() {
let cdp = json!({
"requestTime": 1000.0,
"dnsStart": 0.0, "dnsEnd": 5.0,
"connectStart": 5.0, "connectEnd": 15.0,
"sslStart": 8.0, "sslEnd": 15.0,
"sendStart": 15.0, "sendEnd": 16.0,
"receiveHeadersStart": 16.0, "receiveHeadersEnd": 50.0,
});
let (timings, total) = har_compute_timings(Some(&cdp), Some(1000.1));
assert_eq!(timings["dns"], 5.0);
assert_eq!(timings["connect"], 10.0);
assert_eq!(timings["ssl"], 7.0);
assert_eq!(timings["send"], 1.0);
assert!(total > 0.0);
} }
#[tokio::test] #[tokio::test]
@@ -5762,19 +6039,23 @@ mod tests {
let mut state = DaemonState::new(); let mut state = DaemonState::new();
state.har_recording = true; state.har_recording = true;
state.har_entries.push(HarEntry { state.har_entries.push(HarEntry {
request_id: "req-2".to_string(),
wall_time: 1773576000.0,
method: "GET".to_string(), method: "GET".to_string(),
url: "https://example.com/".to_string(), url: "https://example.com/".to_string(),
status: Some(200), request_headers: vec![("Accept".to_string(), "text/html".to_string())],
status_text: Some("OK".to_string()), post_data: None,
mime_type: Some("text/html".to_string()),
request_id: "req-2".to_string(),
request_headers: json!({ "Accept": "text/html" }),
request_body_size: 0, request_body_size: 0,
response_headers: json!({ "Content-Type": "text/html" }),
started_date_time: "2026-03-15T12:00:00Z".to_string(),
resource_type: "Document".to_string(), resource_type: "Document".to_string(),
http_version: Some("h2".to_string()), status: Some(200),
response_body_size: Some(128), status_text: "OK".to_string(),
http_version: "HTTP/2.0".to_string(),
response_headers: vec![("content-type".to_string(), "text/html".to_string())],
mime_type: "text/html".to_string(),
redirect_url: String::new(),
response_body_size: 128,
cdp_timing: None,
loading_finished_timestamp: None,
}); });
let result = handle_har_stop(&json!({ "action": "har_stop" }), &mut state) let result = handle_har_stop(&json!({ "action": "har_stop" }), &mut state)
@@ -5805,19 +6086,23 @@ mod tests {
)); ));
let mut state = DaemonState::new(); let mut state = DaemonState::new();
state.har_entries.push(HarEntry { state.har_entries.push(HarEntry {
request_id: "req-3".to_string(),
wall_time: 1773576000.0,
method: "GET".to_string(), method: "GET".to_string(),
url: "https://example.com/".to_string(), url: "https://example.com/".to_string(),
status: Some(200), request_headers: vec![],
status_text: Some("OK".to_string()), post_data: None,
mime_type: Some("text/html".to_string()),
request_id: "req-3".to_string(),
request_headers: json!({}),
request_body_size: 0, request_body_size: 0,
response_headers: json!({}),
started_date_time: "2026-03-15T12:00:00Z".to_string(),
resource_type: "Document".to_string(), resource_type: "Document".to_string(),
http_version: Some("h2".to_string()), status: Some(200),
response_body_size: Some(64), status_text: "OK".to_string(),
http_version: "HTTP/1.1".to_string(),
response_headers: vec![],
mime_type: "text/html".to_string(),
redirect_url: String::new(),
response_body_size: 64,
cdp_timing: None,
loading_finished_timestamp: None,
}); });
let result = execute_command( let result = execute_command(