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 unroute [url] # Remove routes
|
||||||
agent-browser network requests # View tracked requests
|
agent-browser network requests # View tracked requests
|
||||||
agent-browser network requests --filter api # Filter 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
|
### Tabs & Windows
|
||||||
|
|||||||
Generated
+53
@@ -60,6 +60,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"similar",
|
"similar",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
"url",
|
"url",
|
||||||
@@ -386,6 +387,15 @@ version = "2.10.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
dependencies = [
|
||||||
|
"powerfmt",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -1174,6 +1184,12 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-derive"
|
name = "num-derive"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -1296,6 +1312,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -1924,6 +1946,37 @@ dependencies = [
|
|||||||
"zune-jpeg 0.4.21",
|
"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]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ aes-gcm = "0.10"
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
similar = "2"
|
similar = "2"
|
||||||
zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
|
zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
|
||||||
|
time = { version = "0.3", features = ["formatting"] }
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
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> {
|
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() {
|
match rest.first().copied() {
|
||||||
Some("route") => {
|
Some("route") => {
|
||||||
@@ -2073,13 +2074,34 @@ fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
|||||||
}
|
}
|
||||||
Ok(cmd)
|
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 {
|
Some(sub) => Err(ParseError::UnknownSubcommand {
|
||||||
subcommand: sub.to_string(),
|
subcommand: sub.to_string(),
|
||||||
valid_options: VALID,
|
valid_options: VALID,
|
||||||
}),
|
}),
|
||||||
None => Err(ParseError::MissingArguments {
|
None => Err(ParseError::MissingArguments {
|
||||||
context: "network".to_string(),
|
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");
|
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 ===
|
// === Screenshot ===
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+406
-28
@@ -1,8 +1,10 @@
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::AtomicU64;
|
use std::sync::atomic::AtomicU64;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||||
use tokio::sync::{broadcast, oneshot, RwLock};
|
use tokio::sync::{broadcast, oneshot, RwLock};
|
||||||
|
|
||||||
use super::auth;
|
use super::auth;
|
||||||
@@ -38,12 +40,21 @@ pub struct PendingConfirmation {
|
|||||||
pub cmd: Value,
|
pub cmd: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Captured request/response metadata used to export HAR 1.2 files.
|
||||||
pub struct HarEntry {
|
pub struct HarEntry {
|
||||||
pub method: String,
|
pub method: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub status: Option<i64>,
|
pub status: Option<i64>,
|
||||||
|
pub status_text: Option<String>,
|
||||||
pub mime_type: Option<String>,
|
pub mime_type: Option<String>,
|
||||||
pub request_id: 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 {
|
pub struct RouteEntry {
|
||||||
@@ -338,12 +349,36 @@ impl DaemonState {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
if self.har_recording {
|
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 {
|
self.har_entries.push(HarEntry {
|
||||||
method: method.clone(),
|
method: method.clone(),
|
||||||
url: url.clone(),
|
url: url.clone(),
|
||||||
status: None,
|
status: None,
|
||||||
|
status_text: None,
|
||||||
mime_type: None,
|
mime_type: None,
|
||||||
request_id,
|
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 {
|
if self.request_tracking {
|
||||||
@@ -381,6 +416,19 @@ impl DaemonState {
|
|||||||
.get("mimeType")
|
.get("mimeType")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(String::from);
|
.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
|
if let Some(entry) = self
|
||||||
.har_entries
|
.har_entries
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
@@ -388,7 +436,35 @@ impl DaemonState {
|
|||||||
.find(|e| e.request_id == request_id)
|
.find(|e| e.request_id == request_id)
|
||||||
{
|
{
|
||||||
entry.status = status;
|
entry.status = status;
|
||||||
|
entry.status_text = status_text;
|
||||||
entry.mime_type = mime_type;
|
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,
|
action,
|
||||||
"" | "launch"
|
"" | "launch"
|
||||||
| "close"
|
| "close"
|
||||||
|
| "har_stop"
|
||||||
| "credentials_set"
|
| "credentials_set"
|
||||||
| "credentials_get"
|
| "credentials_get"
|
||||||
| "credentials_delete"
|
| "credentials_delete"
|
||||||
@@ -4402,6 +4479,7 @@ async fn handle_video_stop(state: &mut DaemonState) -> Result<Value, String> {
|
|||||||
recording::recording_stop(&mut state.recording_state)
|
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> {
|
async fn handle_har_start(state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
let session_id = mgr.active_session_id()?.to_string();
|
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 }))
|
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> {
|
async fn handle_har_stop(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let path = cmd
|
let path = har_output_path(cmd.get("path").and_then(|v| v.as_str()));
|
||||||
.get("path")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or("Missing 'path' parameter")?;
|
|
||||||
|
|
||||||
state.har_recording = false;
|
state.har_recording = false;
|
||||||
|
|
||||||
let entries: Vec<Value> = state
|
let entries: Vec<Value> = state.har_entries.drain(..).map(har_entry_json).collect();
|
||||||
.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 request_count = entries.len();
|
let request_count = entries.len();
|
||||||
|
let browser = har_browser_metadata(state).await;
|
||||||
|
|
||||||
let har = json!({
|
let mut log = json!({
|
||||||
"log": {
|
"version": "1.2",
|
||||||
"version": "1.2",
|
"creator": {
|
||||||
"entries": entries
|
"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)
|
let har_str = serde_json::to_string_pretty(&har)
|
||||||
.map_err(|e| format!("Failed to serialize HAR: {}", e))?;
|
.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 }))
|
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)
|
// Fetch interception resolver (routes + domain filter)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -5340,6 +5584,7 @@ fn error_response(id: &str, error: &str) -> Value {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_utils::EnvGuard;
|
use crate::test_utils::EnvGuard;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_success_response_structure() {
|
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]
|
#[tokio::test]
|
||||||
async fn test_execute_unknown_command() {
|
async fn test_execute_unknown_command() {
|
||||||
let mut state = DaemonState::new();
|
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);
|
println!("{} {}", color::success_indicator(), label);
|
||||||
return;
|
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 let Some(started) = data.get("started").and_then(|v| v.as_bool()) {
|
||||||
if started {
|
if started {
|
||||||
match action {
|
match action {
|
||||||
Some("profiler_start") => {
|
Some("profiler_start") => {
|
||||||
println!("{} Profiling started", color::success_indicator());
|
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()) {
|
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
|
||||||
println!("{} Recording started: {}", color::success_indicator(), path);
|
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)
|
data.get("eventCount").and_then(|c| c.as_u64()).unwrap_or(0)
|
||||||
),
|
),
|
||||||
"har_stop" => println!(
|
"har_stop" => println!(
|
||||||
"{} HAR saved to {}",
|
"{} HAR saved to {} ({} requests)",
|
||||||
color::success_indicator(),
|
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" | "waitfordownload" => println!(
|
||||||
"{} Download saved to {}",
|
"{} Download saved to {}",
|
||||||
@@ -1721,6 +1727,7 @@ Subcommands:
|
|||||||
requests [options] List captured requests
|
requests [options] List captured requests
|
||||||
--clear Clear request log
|
--clear Clear request log
|
||||||
--filter <pattern> Filter by URL pattern
|
--filter <pattern> Filter by URL pattern
|
||||||
|
har <start|stop> [path] Record and export a HAR file
|
||||||
|
|
||||||
Global Options:
|
Global Options:
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
@@ -1733,6 +1740,8 @@ Examples:
|
|||||||
agent-browser network requests
|
agent-browser network requests
|
||||||
agent-browser network requests --filter "api"
|
agent-browser network requests --filter "api"
|
||||||
agent-browser network requests --clear
|
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>]
|
route <url> [--abort|--body <json>]
|
||||||
unroute [url]
|
unroute [url]
|
||||||
requests [--clear] [--filter <pattern>]
|
requests [--clear] [--filter <pattern>]
|
||||||
|
har <start|stop> [path]
|
||||||
|
|
||||||
Storage:
|
Storage:
|
||||||
cookies [get|set|clear] Manage cookies (set supports --url, --domain, --path, --httpOnly, --secure, --sameSite, --expires)
|
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 # View tracked requests
|
||||||
agent-browser network requests --clear # Clear request log
|
agent-browser network requests --clear # Clear request log
|
||||||
agent-browser network requests --filter <pat> # Filter by URL pattern
|
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
|
## 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>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>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>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>
|
<tr><td><code>state</code></td><td>state save/load, cookies set, storage set</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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 wait --download ./output.zip # Wait for any download to complete
|
||||||
agent-browser --download-path ./downloads open <url> # Set default download directory
|
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
|
# Viewport & Device Emulation
|
||||||
agent-browser set viewport 1920 1080 # Set viewport size (default: 1280x720)
|
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)
|
agent-browser set viewport 1920 1080 2 # 2x retina (same CSS size, higher res screenshots)
|
||||||
|
|||||||
Reference in New Issue
Block a user