feat: add network har start/stop command for HAR 1.2 export (#874)
Expose HAR recording as a CLI subcommand under the existing `network` command so users can capture and export network traffic without a separate tool or opening the browser twice. - Parse `network har start` and `network har stop [path]` in commands.rs - Enrich HarEntry with request/response headers, timestamps, status text, resource type, HTTP version, and body sizes from CDP events - Produce HAR 1.2 output with creator/browser metadata, query strings, and proper header arrays compatible with Chrome DevTools HAR viewer - Auto-generate output path under ~/.agent-browser/tmp/har/ when omitted - Add har_stop to skip_launch list so export works without a live browser - Update help text, README, docs site, SKILL.md, and security policy docs - Add unit tests for parsing, HAR entry serialization, and stop behavior
This commit is contained in:
@@ -260,6 +260,8 @@ agent-browser network route <url> --body <json> # Mock response
|
||||
agent-browser network unroute [url] # Remove routes
|
||||
agent-browser network requests # View tracked requests
|
||||
agent-browser network requests --filter api # Filter requests
|
||||
agent-browser network har start # Start HAR recording
|
||||
agent-browser network har stop [output.har] # Stop and save HAR (temp path if omitted)
|
||||
```
|
||||
|
||||
### Tabs & Windows
|
||||
|
||||
Generated
+53
@@ -60,6 +60,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"similar",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
@@ -386,6 +387,15 @@ version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1174,6 +1184,12 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
@@ -1296,6 +1312,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -1924,6 +1946,37 @@ dependencies = [
|
||||
"zune-jpeg 0.4.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.2"
|
||||
|
||||
@@ -28,6 +28,7 @@ aes-gcm = "0.10"
|
||||
async-trait = "0.1"
|
||||
similar = "2"
|
||||
zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
+52
-2
@@ -2042,8 +2042,9 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse network interception, request inspection, and HAR recording commands.
|
||||
fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
const VALID: &[&str] = &["route", "unroute", "requests"];
|
||||
const VALID: &[&str] = &["route", "unroute", "requests", "har"];
|
||||
|
||||
match rest.first().copied() {
|
||||
Some("route") => {
|
||||
@@ -2073,13 +2074,34 @@ fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some("har") => {
|
||||
const HAR_VALID: &[&str] = &["start", "stop"];
|
||||
match rest.get(1).copied() {
|
||||
Some("start") => Ok(json!({ "id": id, "action": "har_start" })),
|
||||
Some("stop") => {
|
||||
let mut cmd = json!({ "id": id, "action": "har_stop" });
|
||||
if let Some(path) = rest.get(2) {
|
||||
cmd["path"] = json!(path);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||
subcommand: sub.to_string(),
|
||||
valid_options: HAR_VALID,
|
||||
}),
|
||||
None => Err(ParseError::MissingArguments {
|
||||
context: "network har".to_string(),
|
||||
usage: "network har <start|stop> [path]",
|
||||
}),
|
||||
}
|
||||
}
|
||||
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||
subcommand: sub.to_string(),
|
||||
valid_options: VALID,
|
||||
}),
|
||||
None => Err(ParseError::MissingArguments {
|
||||
context: "network".to_string(),
|
||||
usage: "network <route|unroute|requests> [args...]",
|
||||
usage: "network <route|unroute|requests|har> [args...]",
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -2659,6 +2681,34 @@ mod tests {
|
||||
assert_eq!(cmd["action"], "tab_close");
|
||||
}
|
||||
|
||||
// === Network ===
|
||||
|
||||
#[test]
|
||||
fn test_network_har_start() {
|
||||
let cmd = parse_command(&args("network har start"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "har_start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_har_stop_with_path() {
|
||||
let cmd = parse_command(&args("network har stop ./capture.har"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "har_stop");
|
||||
assert_eq!(cmd["path"], "./capture.har");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_har_stop_without_path() {
|
||||
let cmd = parse_command(&args("network har stop"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "har_stop");
|
||||
assert!(cmd.get("path").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_har_requires_subcommand() {
|
||||
let result = parse_command(&args("network har"), &default_flags());
|
||||
assert!(matches!(result, Err(ParseError::MissingArguments { .. })));
|
||||
}
|
||||
|
||||
// === Screenshot ===
|
||||
|
||||
#[test]
|
||||
|
||||
+406
-28
@@ -1,8 +1,10 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::Arc;
|
||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||
use tokio::sync::{broadcast, oneshot, RwLock};
|
||||
|
||||
use super::auth;
|
||||
@@ -38,12 +40,21 @@ pub struct PendingConfirmation {
|
||||
pub cmd: Value,
|
||||
}
|
||||
|
||||
/// Captured request/response metadata used to export HAR 1.2 files.
|
||||
pub struct HarEntry {
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
pub status: Option<i64>,
|
||||
pub status_text: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
pub request_id: String,
|
||||
pub request_headers: Value,
|
||||
pub request_body_size: i64,
|
||||
pub response_headers: Value,
|
||||
pub started_date_time: String,
|
||||
pub resource_type: String,
|
||||
pub http_version: Option<String>,
|
||||
pub response_body_size: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct RouteEntry {
|
||||
@@ -338,12 +349,36 @@ impl DaemonState {
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if self.har_recording {
|
||||
let headers =
|
||||
request.get("headers").cloned().unwrap_or(json!({}));
|
||||
let request_body_size = request
|
||||
.get("postData")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|body| body.len() as i64)
|
||||
.unwrap_or(0);
|
||||
let resource_type = event
|
||||
.params
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Other")
|
||||
.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 {
|
||||
method: method.clone(),
|
||||
url: url.clone(),
|
||||
status: None,
|
||||
status_text: None,
|
||||
mime_type: None,
|
||||
request_id,
|
||||
request_headers: headers,
|
||||
request_body_size,
|
||||
response_headers: json!({}),
|
||||
started_date_time,
|
||||
resource_type,
|
||||
http_version: None,
|
||||
response_body_size: None,
|
||||
});
|
||||
}
|
||||
if self.request_tracking {
|
||||
@@ -381,6 +416,19 @@ impl DaemonState {
|
||||
.get("mimeType")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let status_text = response
|
||||
.get("statusText")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let headers = response.get("headers").cloned().unwrap_or(json!({}));
|
||||
let http_version = response
|
||||
.get("protocol")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let response_body_size = response
|
||||
.get("encodedDataLength")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|size| size.max(0.0) as i64);
|
||||
if let Some(entry) = self
|
||||
.har_entries
|
||||
.iter_mut()
|
||||
@@ -388,7 +436,35 @@ impl DaemonState {
|
||||
.find(|e| e.request_id == request_id)
|
||||
{
|
||||
entry.status = status;
|
||||
entry.status_text = status_text;
|
||||
entry.mime_type = mime_type;
|
||||
entry.response_headers = headers;
|
||||
entry.http_version = http_version;
|
||||
if response_body_size.is_some() {
|
||||
entry.response_body_size = response_body_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"Network.loadingFinished" if self.har_recording => {
|
||||
let request_id = event
|
||||
.params
|
||||
.get("requestId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let encoded_data_length = event
|
||||
.params
|
||||
.get("encodedDataLength")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|size| size.max(0.0) as i64);
|
||||
if let Some(entry) = self
|
||||
.har_entries
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|e| e.request_id == request_id)
|
||||
{
|
||||
if encoded_data_length.is_some() {
|
||||
entry.response_body_size = encoded_data_length;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -571,6 +647,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
action,
|
||||
"" | "launch"
|
||||
| "close"
|
||||
| "har_stop"
|
||||
| "credentials_set"
|
||||
| "credentials_get"
|
||||
| "credentials_delete"
|
||||
@@ -4402,6 +4479,7 @@ async fn handle_video_stop(state: &mut DaemonState) -> Result<Value, String> {
|
||||
recording::recording_stop(&mut state.recording_state)
|
||||
}
|
||||
|
||||
/// Begin capturing network traffic for a later HAR export.
|
||||
async fn handle_har_start(state: &mut DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let session_id = mgr.active_session_id()?.to_string();
|
||||
@@ -4413,48 +4491,214 @@ async fn handle_har_start(state: &mut DaemonState) -> Result<Value, String> {
|
||||
Ok(json!({ "started": true }))
|
||||
}
|
||||
|
||||
/// Stop HAR recording and write the captured requests to disk.
|
||||
async fn handle_har_stop(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||
let path = cmd
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing 'path' parameter")?;
|
||||
let path = har_output_path(cmd.get("path").and_then(|v| v.as_str()));
|
||||
|
||||
state.har_recording = false;
|
||||
|
||||
let entries: Vec<Value> = state
|
||||
.har_entries
|
||||
.drain(..)
|
||||
.map(|e| {
|
||||
json!({
|
||||
"request": {
|
||||
"method": e.method,
|
||||
"url": e.url,
|
||||
},
|
||||
"response": {
|
||||
"status": e.status.unwrap_or(0),
|
||||
"content": {
|
||||
"mimeType": e.mime_type.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let entries: Vec<Value> = state.har_entries.drain(..).map(har_entry_json).collect();
|
||||
let request_count = entries.len();
|
||||
let browser = har_browser_metadata(state).await;
|
||||
|
||||
let har = json!({
|
||||
"log": {
|
||||
"version": "1.2",
|
||||
"entries": entries
|
||||
}
|
||||
let mut log = json!({
|
||||
"version": "1.2",
|
||||
"creator": {
|
||||
"name": "agent-browser",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
},
|
||||
"entries": entries
|
||||
});
|
||||
if let Some(browser) = browser {
|
||||
log["browser"] = browser;
|
||||
}
|
||||
let har = json!({ "log": log });
|
||||
|
||||
let har_str = serde_json::to_string_pretty(&har)
|
||||
.map_err(|e| format!("Failed to serialize HAR: {}", e))?;
|
||||
std::fs::write(path, har_str).map_err(|e| format!("Failed to write HAR: {}", e))?;
|
||||
std::fs::write(&path, har_str).map_err(|e| format!("Failed to write HAR: {}", e))?;
|
||||
|
||||
Ok(json!({ "path": path, "requestCount": request_count }))
|
||||
}
|
||||
|
||||
fn har_entry_json(entry: HarEntry) -> Value {
|
||||
let query_string = har_query_string(&entry.url);
|
||||
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();
|
||||
let body_size = entry.response_body_size.unwrap_or(-1);
|
||||
let content_size = entry.response_body_size.unwrap_or(0);
|
||||
|
||||
json!({
|
||||
"startedDateTime": entry.started_date_time,
|
||||
"time": 0,
|
||||
"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": {
|
||||
"status": entry.status.unwrap_or(0),
|
||||
"statusText": entry.status_text.unwrap_or_default(),
|
||||
"httpVersion": http_version,
|
||||
"cookies": [],
|
||||
"headers": response_headers,
|
||||
"content": {
|
||||
"size": content_size,
|
||||
"mimeType": entry.mime_type.unwrap_or_default(),
|
||||
},
|
||||
"redirectURL": har_redirect_url(&entry.response_headers),
|
||||
"headersSize": -1,
|
||||
"bodySize": body_size,
|
||||
},
|
||||
"cache": {},
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"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.
|
||||
fn har_headers(headers: &Value) -> Vec<Value> {
|
||||
let Some(obj) = headers.as_object() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut entries: Vec<(&String, &Value)> = obj.iter().collect();
|
||||
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(name, value)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"value": har_header_value(value),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn har_header_value(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => String::new(),
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.map(har_header_value)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
Value::Object(_) => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn har_query_string(raw_url: &str) -> Vec<Value> {
|
||||
let Ok(url) = url::Url::parse(raw_url) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
url.query_pairs()
|
||||
.map(|(name, value)| {
|
||||
json!({
|
||||
"name": name.into_owned(),
|
||||
"value": value.into_owned(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn har_redirect_url(headers: &Value) -> String {
|
||||
har_header_lookup(headers, "location").unwrap_or_default()
|
||||
}
|
||||
|
||||
fn har_header_lookup(headers: &Value, expected_name: &str) -> Option<String> {
|
||||
headers.as_object().and_then(|obj| {
|
||||
obj.iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case(expected_name))
|
||||
.map(|(_, value)| har_header_value(value))
|
||||
})
|
||||
}
|
||||
|
||||
fn har_output_path(explicit_path: Option<&str>) -> String {
|
||||
match explicit_path {
|
||||
Some(path) => path.to_string(),
|
||||
None => {
|
||||
let dir = get_har_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir.join(format!("har-{}.har", unix_timestamp_millis()))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_har_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("har")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("har")
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_timestamp_millis() -> u128 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.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> {
|
||||
let mgr = state.browser.as_ref()?;
|
||||
if !mgr.is_connection_alive().await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let version = mgr
|
||||
.client
|
||||
.send_command_no_params("Browser.getVersion", None)
|
||||
.await
|
||||
.ok()?;
|
||||
browser_metadata_from_version(&version)
|
||||
}
|
||||
|
||||
fn browser_metadata_from_version(version: &Value) -> Option<Value> {
|
||||
let product = version.get("product").and_then(|v| v.as_str())?;
|
||||
let (name, browser_version) = product.split_once('/').unwrap_or((product, ""));
|
||||
Some(json!({
|
||||
"name": name,
|
||||
"version": browser_version,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch interception resolver (routes + domain filter)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -5340,6 +5584,7 @@ fn error_response(id: &str, error: &str) -> Value {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn test_success_response_structure() {
|
||||
@@ -5388,6 +5633,139 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_har_entry_json_enriches_request_and_response() {
|
||||
let entry = HarEntry {
|
||||
method: "POST".to_string(),
|
||||
url: "https://example.com/api?foo=bar&baz=qux".to_string(),
|
||||
status: Some(201),
|
||||
status_text: Some("Created".to_string()),
|
||||
mime_type: Some("application/json".to_string()),
|
||||
request_id: "req-1".to_string(),
|
||||
request_headers: json!({
|
||||
"Accept": "application/json",
|
||||
"X-Trace": ["a", "b"],
|
||||
}),
|
||||
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(),
|
||||
http_version: Some("h2".to_string()),
|
||||
response_body_size: Some(42),
|
||||
};
|
||||
|
||||
let har = har_entry_json(entry);
|
||||
assert_eq!(har["startedDateTime"], "2026-03-15T12:00:00Z");
|
||||
assert_eq!(har["request"]["method"], "POST");
|
||||
assert_eq!(har["request"]["httpVersion"], "h2");
|
||||
assert_eq!(har["request"]["queryString"][0]["name"], "foo");
|
||||
assert_eq!(har["request"]["queryString"][0]["value"], "bar");
|
||||
assert_eq!(har["request"]["bodySize"], 17);
|
||||
assert_eq!(har["response"]["status"], 201);
|
||||
assert_eq!(har["response"]["statusText"], "Created");
|
||||
assert_eq!(har["response"]["content"]["mimeType"], "application/json");
|
||||
assert_eq!(har["response"]["content"]["size"], 42);
|
||||
assert_eq!(har["response"]["redirectURL"], "https://example.com/api/1");
|
||||
assert_eq!(har["_resourceType"], "XHR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_har_started_date_time_uses_rfc3339() {
|
||||
assert_eq!(har_started_date_time(Some(0.0)), "1970-01-01T00:00:00Z");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handle_har_stop_without_path_uses_default_location() {
|
||||
let mut state = DaemonState::new();
|
||||
state.har_recording = true;
|
||||
state.har_entries.push(HarEntry {
|
||||
method: "GET".to_string(),
|
||||
url: "https://example.com/".to_string(),
|
||||
status: Some(200),
|
||||
status_text: Some("OK".to_string()),
|
||||
mime_type: Some("text/html".to_string()),
|
||||
request_id: "req-2".to_string(),
|
||||
request_headers: json!({ "Accept": "text/html" }),
|
||||
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(),
|
||||
http_version: Some("h2".to_string()),
|
||||
response_body_size: Some(128),
|
||||
});
|
||||
|
||||
let result = handle_har_stop(&json!({ "action": "har_stop" }), &mut state)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let path = result["path"].as_str().unwrap();
|
||||
assert!(path.ends_with(".har"));
|
||||
assert!(std::path::Path::new(path).starts_with(get_har_dir()));
|
||||
assert_eq!(result["requestCount"], 1);
|
||||
assert!(!state.har_recording);
|
||||
assert!(state.har_entries.is_empty());
|
||||
|
||||
let har: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
|
||||
assert_eq!(har["log"]["version"], "1.2");
|
||||
assert_eq!(har["log"]["creator"]["name"], "agent-browser");
|
||||
assert!(har["log"].get("browser").is_none());
|
||||
assert_eq!(har["log"]["entries"][0]["response"]["content"]["size"], 128);
|
||||
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_har_stop_skips_browser_auto_launch() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"agent-browser-har-stop-{}.har",
|
||||
unix_timestamp_millis()
|
||||
));
|
||||
let mut state = DaemonState::new();
|
||||
state.har_entries.push(HarEntry {
|
||||
method: "GET".to_string(),
|
||||
url: "https://example.com/".to_string(),
|
||||
status: Some(200),
|
||||
status_text: Some("OK".to_string()),
|
||||
mime_type: Some("text/html".to_string()),
|
||||
request_id: "req-3".to_string(),
|
||||
request_headers: json!({}),
|
||||
request_body_size: 0,
|
||||
response_headers: json!({}),
|
||||
started_date_time: "2026-03-15T12:00:00Z".to_string(),
|
||||
resource_type: "Document".to_string(),
|
||||
http_version: Some("h2".to_string()),
|
||||
response_body_size: Some(64),
|
||||
});
|
||||
|
||||
let result = execute_command(
|
||||
&json!({
|
||||
"action": "har_stop",
|
||||
"id": "har-stop-1",
|
||||
"path": path.to_string_lossy().to_string()
|
||||
}),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result["success"], true);
|
||||
assert_eq!(result["data"]["requestCount"], 1);
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_browser_metadata_from_version_parses_product() {
|
||||
let metadata = browser_metadata_from_version(&json!({
|
||||
"product": "HeadlessChrome/123.0.6312.0"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(metadata["name"], "HeadlessChrome");
|
||||
assert_eq!(metadata["version"], "123.0.6312.0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_unknown_command() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
+13
-3
@@ -456,13 +456,16 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
println!("{} {}", color::success_indicator(), label);
|
||||
return;
|
||||
}
|
||||
// Recording start (has "started" field)
|
||||
// Started actions (profiling, HAR, recording)
|
||||
if let Some(started) = data.get("started").and_then(|v| v.as_bool()) {
|
||||
if started {
|
||||
match action {
|
||||
Some("profiler_start") => {
|
||||
println!("{} Profiling started", color::success_indicator());
|
||||
}
|
||||
Some("har_start") => {
|
||||
println!("{} HAR recording started", color::success_indicator());
|
||||
}
|
||||
_ => {
|
||||
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
|
||||
println!("{} Recording started: {}", color::success_indicator(), path);
|
||||
@@ -591,9 +594,12 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
data.get("eventCount").and_then(|c| c.as_u64()).unwrap_or(0)
|
||||
),
|
||||
"har_stop" => println!(
|
||||
"{} HAR saved to {}",
|
||||
"{} HAR saved to {} ({} requests)",
|
||||
color::success_indicator(),
|
||||
color::green(path)
|
||||
color::green(path),
|
||||
data.get("requestCount")
|
||||
.and_then(|c| c.as_u64())
|
||||
.unwrap_or(0)
|
||||
),
|
||||
"download" | "waitfordownload" => println!(
|
||||
"{} Download saved to {}",
|
||||
@@ -1721,6 +1727,7 @@ Subcommands:
|
||||
requests [options] List captured requests
|
||||
--clear Clear request log
|
||||
--filter <pattern> Filter by URL pattern
|
||||
har <start|stop> [path] Record and export a HAR file
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
@@ -1733,6 +1740,8 @@ Examples:
|
||||
agent-browser network requests
|
||||
agent-browser network requests --filter "api"
|
||||
agent-browser network requests --clear
|
||||
agent-browser network har start
|
||||
agent-browser network har stop ./capture.har
|
||||
"##
|
||||
}
|
||||
|
||||
@@ -2516,6 +2525,7 @@ Network: agent-browser network <action>
|
||||
route <url> [--abort|--body <json>]
|
||||
unroute [url]
|
||||
requests [--clear] [--filter <pattern>]
|
||||
har <start|stop> [path]
|
||||
|
||||
Storage:
|
||||
cookies [get|set|clear] Manage cookies (set supports --url, --domain, --path, --httpOnly, --secure, --sameSite, --expires)
|
||||
|
||||
@@ -177,6 +177,8 @@ agent-browser network unroute [url] # Remove routes
|
||||
agent-browser network requests # View tracked requests
|
||||
agent-browser network requests --clear # Clear request log
|
||||
agent-browser network requests --filter <pat> # Filter by URL pattern
|
||||
agent-browser network har start # Start HAR recording
|
||||
agent-browser network har stop [output.har] # Stop and save HAR (temp path if omitted)
|
||||
```
|
||||
|
||||
## Tabs & frames
|
||||
|
||||
@@ -169,7 +169,7 @@ Example policy (restrictive):
|
||||
<tr><td><code>wait</code></td><td>wait, waitforurl, waitforloadstate, waitforfunction</td></tr>
|
||||
<tr><td><code>get</code></td><td>get text/html/url/title, count, isvisible, getbyrole, getbytext, getbylabel, etc.</td></tr>
|
||||
<tr><td><code>interact</code></td><td>hover, focus, drag, press, keydown, keyup, mousemove, dispatch</td></tr>
|
||||
<tr><td><code>network</code></td><td>network route/unroute, requests</td></tr>
|
||||
<tr><td><code>network</code></td><td>network route/unroute, requests, har start/stop</td></tr>
|
||||
<tr><td><code>state</code></td><td>state save/load, cookies set, storage set</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -147,6 +147,12 @@ agent-browser download @e1 ./file.pdf # Click element to trigger downlo
|
||||
agent-browser wait --download ./output.zip # Wait for any download to complete
|
||||
agent-browser --download-path ./downloads open <url> # Set default download directory
|
||||
|
||||
# Network
|
||||
agent-browser network requests # Inspect tracked requests
|
||||
agent-browser network route "**/api/*" --abort # Block matching requests
|
||||
agent-browser network har start # Start HAR recording
|
||||
agent-browser network har stop ./capture.har # Stop and save HAR file
|
||||
|
||||
# Viewport & Device Emulation
|
||||
agent-browser set viewport 1920 1080 # Set viewport size (default: 1280x720)
|
||||
agent-browser set viewport 1920 1080 2 # 2x retina (same CSS size, higher res screenshots)
|
||||
|
||||
Reference in New Issue
Block a user