use std::time::Duration;
use serde::Serialize;
use agentchrome::cdp::{CdpClient, KeepAliveConfig};
use agentchrome::connection::{ManagedSession, ReconnectPolicy, resolve_target};
use agentchrome::error::{AppError, ExitCode};
use crate::cli::{GlobalOpts, OutputFormat};
use crate::emulate::apply_emulate_state;
use crate::snapshot;
pub const DEFAULT_THRESHOLD: usize = 16_384;
#[derive(Serialize)]
pub struct TempFileOutput {
pub output_file: String,
pub size_bytes: u64,
pub command: String,
pub summary: serde_json::Value,
}
pub fn write_temp_file(content: &str, extension: &str) -> Result<String, AppError> {
let id = uuid::Uuid::new_v4();
let filename = format!("agentchrome-{id}.{extension}");
let path = std::env::temp_dir().join(filename);
std::fs::write(&path, content).map_err(|e| AppError {
message: format!("failed to write temp file {}: {e}", path.display()),
code: ExitCode::GeneralError,
custom_json: None,
})?;
Ok(path.to_string_lossy().into_owned())
}
#[allow(clippy::needless_pass_by_value)]
fn serialization_error(e: serde_json::Error) -> AppError {
AppError {
message: format!("serialization error: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
}
}
#[cfg(test)]
#[allow(clippy::cast_precision_loss)]
pub fn format_human_size(bytes: u64) -> String {
if bytes >= 1_048_576 {
format!("{:.1} MB", bytes as f64 / 1_048_576.0)
} else if bytes >= 1024 {
format!("{} KB", bytes / 1024)
} else {
format!("{bytes} bytes")
}
}
pub fn print_output(value: &impl Serialize, output: &OutputFormat) -> Result<(), AppError> {
let json = if output.pretty {
serde_json::to_string_pretty(value)
} else {
serde_json::to_string(value)
};
let json = json.map_err(serialization_error)?;
println!("{json}");
Ok(())
}
pub fn build_keepalive(global: &GlobalOpts) -> KeepAliveConfig {
if global.no_keepalive {
return KeepAliveConfig {
interval: None,
..KeepAliveConfig::default()
};
}
match global.keepalive_interval {
Some(0) => KeepAliveConfig {
interval: None,
..KeepAliveConfig::default()
},
Some(ms) => KeepAliveConfig {
interval: Some(Duration::from_millis(ms)),
..KeepAliveConfig::default()
},
None => KeepAliveConfig::default(),
}
}
pub async fn connect_from_global(
global: &GlobalOpts,
) -> Result<agentchrome::connection::CommandConnection, AppError> {
connect_from_global_with_timeout(global, global.timeout).await
}
pub async fn connect_from_global_with_timeout(
global: &GlobalOpts,
timeout_ms: Option<u64>,
) -> Result<agentchrome::connection::CommandConnection, AppError> {
let policy = ReconnectPolicy::default();
let keepalive = build_keepalive(global);
agentchrome::connection::connect_for_command(
&global.host,
global.port,
global.ws_url.as_deref(),
timeout_ms,
keepalive,
&policy,
)
.await
}
pub async fn setup_session(global: &GlobalOpts) -> Result<(CdpClient, ManagedSession), AppError> {
let conn = connect_from_global(global).await?;
let target = resolve_target(
&conn.resolved.host,
conn.resolved.port,
global.tab.as_deref(),
global.page_id.as_deref(),
)
.await?;
let session = conn.client.create_session(&target.id).await?;
let mut managed = ManagedSession::new(session);
apply_emulate_state(&mut managed).await?;
Ok((conn.client, managed))
}
pub async fn setup_session_bare(
global: &GlobalOpts,
) -> Result<(CdpClient, ManagedSession), AppError> {
let conn = connect_from_global(global).await?;
let target = resolve_target(
&conn.resolved.host,
conn.resolved.port,
global.tab.as_deref(),
global.page_id.as_deref(),
)
.await?;
let session = conn.client.create_session(&target.id).await?;
let managed = ManagedSession::new(session);
Ok((conn.client, managed))
}
pub async fn setup_session_with_interceptors(
global: &GlobalOpts,
) -> Result<(CdpClient, ManagedSession), AppError> {
let (client, managed) = setup_session(global).await?;
managed.install_dialog_interceptors().await;
Ok((client, managed))
}
pub async fn resolve_optional_frame(
client: &CdpClient,
managed: &mut ManagedSession,
frame: Option<&str>,
uid: Option<&str>,
) -> Result<Option<agentchrome::frame::FrameContext>, AppError> {
if let Some(uid_str) = uid {
if snapshot::is_uid(uid_str) {
if let Some(state) = snapshot::read_snapshot_state().ok().flatten() {
if let Some(uid_frame_idx) = snapshot::aggregate_frame_for_uid(&state, uid_str) {
let recorded_frame_id =
snapshot::aggregate_frame_id(&state, uid_frame_idx).map(str::to_string);
if let Some(frame_str) = frame {
if let Ok(agentchrome::frame::FrameArg::Index(n)) =
agentchrome::frame::parse_frame_arg(frame_str)
{
if n != uid_frame_idx {
eprintln!(
"warning: UID {uid_str} was recorded in frame {uid_frame_idx} but --frame {n} was supplied; proceeding with explicit frame"
);
}
}
} else {
verify_frame_still_attached(
managed,
uid_frame_idx,
recorded_frame_id.as_deref(),
)
.await?;
if uid_frame_idx == 0 {
return Ok(None);
}
let arg = agentchrome::frame::FrameArg::Index(uid_frame_idx);
let ctx = agentchrome::frame::resolve_frame(client, managed, &arg).await?;
return Ok(Some(ctx));
}
}
}
}
}
if let Some(frame_str) = frame {
let arg = agentchrome::frame::parse_frame_arg(frame_str)?;
if matches!(arg, agentchrome::frame::FrameArg::Auto) {
let target_uid = uid.unwrap_or_default();
let state = snapshot::read_snapshot_state().ok().flatten();
let hint = state
.as_ref()
.and_then(|s| s.frame_index.map(|idx| (idx, &s.uid_map)));
let (ctx, _frame_idx) =
agentchrome::frame::resolve_frame_auto(client, managed, target_uid, hint).await?;
Ok(Some(ctx))
} else {
let ctx = agentchrome::frame::resolve_frame(client, managed, &arg).await?;
Ok(Some(ctx))
}
} else {
Ok(None)
}
}
async fn verify_frame_still_attached(
managed: &mut ManagedSession,
frame_index: u32,
recorded_frame_id: Option<&str>,
) -> Result<(), AppError> {
let Some(expected_id) = recorded_frame_id else {
return Ok(());
};
let frames = agentchrome::frame::list_frames(managed).await?;
let Some(current) = frames.iter().find(|f| f.index == frame_index) else {
return Err(AppError::frame_detached());
};
if current.id != expected_id {
return Err(AppError::frame_detached());
}
Ok(())
}
pub fn emit_plain(text: &str, output: &OutputFormat) -> Result<(), AppError> {
let threshold = output.large_response_threshold.unwrap_or(DEFAULT_THRESHOLD);
if text.len() <= threshold {
print!("{text}");
return Ok(());
}
let path = write_temp_file(text, "txt")?;
println!("{path}");
Ok(())
}
pub fn emit<T, F>(
value: &T,
output: &OutputFormat,
command_name: &str,
summary_fn: F,
) -> Result<(), AppError>
where
T: Serialize,
F: FnOnce(&T) -> serde_json::Value,
{
let json_string = if output.pretty {
serde_json::to_string_pretty(value)
} else {
serde_json::to_string(value)
}
.map_err(serialization_error)?;
let threshold = output.large_response_threshold.unwrap_or(DEFAULT_THRESHOLD);
if json_string.len() <= threshold {
println!("{json_string}");
return Ok(());
}
let path = write_temp_file(&json_string, "json")?;
let summary = summary_fn(value);
#[allow(clippy::cast_possible_truncation)]
let size_bytes = json_string.len() as u64;
let temp_output = TempFileOutput {
output_file: path,
size_bytes,
command: command_name.to_string(),
summary,
};
let output_json = serde_json::to_string(&temp_output).map_err(serialization_error)?;
println!("{output_json}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_human_size_bytes() {
assert_eq!(format_human_size(500), "500 bytes");
assert_eq!(format_human_size(0), "0 bytes");
assert_eq!(format_human_size(1023), "1023 bytes");
}
#[test]
fn format_human_size_kb() {
assert_eq!(format_human_size(1024), "1 KB");
assert_eq!(format_human_size(16_384), "16 KB");
assert_eq!(format_human_size(1_048_575), "1023 KB");
}
#[test]
fn format_human_size_mb() {
assert_eq!(format_human_size(1_048_576), "1.0 MB");
assert_eq!(format_human_size(5_242_880), "5.0 MB");
}
#[test]
fn temp_file_output_serialization() {
let output = TempFileOutput {
output_file: "/tmp/agentchrome-abc.json".to_string(),
size_bytes: 32_768,
command: "page snapshot".to_string(),
summary: serde_json::json!({"total_nodes": 5000}),
};
let json: serde_json::Value = serde_json::to_value(&output).unwrap();
assert_eq!(json["output_file"], "/tmp/agentchrome-abc.json");
assert_eq!(json["size_bytes"], 32_768);
assert_eq!(json["command"], "page snapshot");
assert_eq!(json["summary"]["total_nodes"], 5000);
}
#[test]
fn temp_file_output_has_exactly_four_keys() {
let output = TempFileOutput {
output_file: "/tmp/test.json".to_string(),
size_bytes: 100,
command: "test".to_string(),
summary: serde_json::json!({}),
};
let json: serde_json::Value = serde_json::to_value(&output).unwrap();
let keys = json.as_object().unwrap();
assert_eq!(keys.len(), 4);
assert!(keys.contains_key("output_file"));
assert!(keys.contains_key("size_bytes"));
assert!(keys.contains_key("command"));
assert!(keys.contains_key("summary"));
}
#[test]
fn write_temp_file_creates_readable_file() {
let content = "hello temp file";
let path = write_temp_file(content, "txt").unwrap();
let read_back = std::fs::read_to_string(&path).unwrap();
assert_eq!(read_back, content);
assert!(path.contains("agentchrome-"));
assert!(
std::path::Path::new(&path)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn write_temp_file_json_extension() {
let content = r#"{"key":"value"}"#;
let path = write_temp_file(content, "json").unwrap();
assert!(
std::path::Path::new(&path)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn write_temp_file_uuid_uniqueness() {
let path1 = write_temp_file("a", "txt").unwrap();
let path2 = write_temp_file("b", "txt").unwrap();
assert_ne!(path1, path2);
let _ = std::fs::remove_file(&path1);
let _ = std::fs::remove_file(&path2);
}
#[test]
fn emit_below_threshold_prints_json() {
let value = serde_json::json!({"key": "value"});
let output = OutputFormat {
json: true,
pretty: false,
plain: false,
large_response_threshold: Some(1_000_000),
};
let result = emit(&value, &output, "test", |_| serde_json::json!({}));
assert!(result.is_ok());
}
#[test]
fn emit_above_threshold_creates_temp_file() {
let large_string: String = "x".repeat(1000);
let value = serde_json::json!({"data": large_string});
let output = OutputFormat {
json: true,
pretty: false,
plain: false,
large_response_threshold: Some(10), };
let result = emit(
&value,
&output,
"test cmd",
|_| serde_json::json!({"test": true}),
);
assert!(result.is_ok());
}
#[test]
fn emit_plain_below_threshold() {
let output = OutputFormat {
json: false,
pretty: false,
plain: true,
large_response_threshold: Some(1_000_000),
};
let result = emit_plain("short text", &output);
assert!(result.is_ok());
}
#[test]
fn emit_plain_above_threshold() {
let output = OutputFormat {
json: false,
pretty: false,
plain: true,
large_response_threshold: Some(5), };
let result = emit_plain("this text is longer than 5 bytes", &output);
assert!(result.is_ok());
}
}