pub mod argenums;
pub mod capabilities;
pub mod cdp;
pub mod container;
pub mod fetch;
pub mod health;
pub mod host;
pub mod panel;
pub mod profile;
pub mod skill;
pub mod tabs;
pub mod upload;
#[cfg(test)]
mod tests {
use std::time::Duration;
use axum::routing::get;
use futures::{SinkExt, StreamExt};
use serde_json::{Value, json};
use tokio::net::{TcpListener, TcpStream};
use tokio_tungstenite::tungstenite::Message;
use super::*;
use crate::shared::error::ErrorCode;
fn ensure_rustls_provider() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
async fn spawn_http_host() -> String {
ensure_rustls_provider();
let app = axum::Router::new()
.route(
"/health",
get(|| async {
axum::Json(crate::shared::afdata::result_value(
"health",
json!({
"code": "health",
"status": "ok",
"version": env!("CARGO_PKG_VERSION"),
"uptime_s": 1,
"tabs_active": 2,
"capabilities_url": "/capabilities",
}),
))
}),
)
.route(
"/capabilities",
get(|| async {
axum::Json(crate::shared::afdata::result_value(
"capabilities",
json!({
"code": "capabilities",
"backend": {"family": "test", "version": "1"},
"artifacts": {
"body": {"supported": true},
"network": {"supported": true, "body_capture": ["xhr"]},
"screenshot": {"supported": false}
},
"wait_modes": ["auto", "load"],
"takeover": {"supported": false, "backend_capable": false},
"profile": {"persistent": true, "ephemeral": true},
"features": {},
"limits": {}
}),
))
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
format!("http://{addr}")
}
async fn spawn_cdp() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(handle_cdp(stream));
}
});
format!("ws://{addr}")
}
async fn handle_cdp(stream: TcpStream) {
let Ok(ws) = tokio_tungstenite::accept_async(stream).await else {
return;
};
let (mut tx, mut rx) = ws.split();
while let Some(Ok(Message::Text(text))) = rx.next().await {
let Ok(value) = serde_json::from_str::<Value>(text.as_str()) else {
continue;
};
let id = value.get("id").and_then(Value::as_i64).unwrap_or(0);
let method = value.get("method").and_then(Value::as_str).unwrap_or("");
let params = value.get("params").cloned().unwrap_or(Value::Null);
let session_id = value
.get("sessionId")
.and_then(Value::as_str)
.unwrap_or("session-1")
.to_string();
let (result, delayed_event) = cdp_response(method, ¶ms, &session_id);
let response = json!({"id": id, "result": result}).to_string();
if tx.send(Message::Text(response.into())).await.is_err() {
return;
}
if let Some(event) = delayed_event {
tokio::time::sleep(Duration::from_millis(25)).await;
let _ = tx.send(Message::Text(event.to_string().into())).await;
}
}
}
fn cdp_response(method: &str, params: &Value, session_id: &str) -> (Value, Option<Value>) {
match method {
"Target.getTargets" => (
json!({
"targetInfos": [{
"targetId": "tab-1",
"type": "page",
"title": "Example",
"url": "https://example.test/"
}]
}),
None,
),
"Target.attachToTarget" => (json!({"sessionId": "session-1"}), None),
"Target.closeTarget" => (json!({"success": true}), None),
"Target.detachFromTarget"
| "Runtime.enable"
| "DOM.enable"
| "DOM.setFileInputFiles" => (json!({}), None),
"DOM.getDocument" => (json!({"root": {"nodeId": 1}}), None),
"DOM.querySelector" => (json!({"nodeId": 2}), None),
"DOM.describeNode" => (
json!({"node": {"nodeName": "INPUT", "attributes": ["type", "file"]}}),
None,
),
"Runtime.evaluate" => runtime_evaluate_response(params, session_id),
_ => (json!({"ok": true}), None),
}
}
fn runtime_evaluate_response(params: &Value, session_id: &str) -> (Value, Option<Value>) {
let expression = params
.get("expression")
.and_then(Value::as_str)
.unwrap_or("");
if expression == "location.href" {
return (json!({"result": {"value": "https://example.test/"}}), None);
}
if expression.contains("document.title") {
return (
json!({"result": {"value": "{\"title\":\"Example\",\"w\":800,\"h\":600,\"dpr\":1}"}}),
None,
);
}
if expression.contains("AFHTTP_OBSERVATION_SNAPSHOT") {
return (
json!({"result": {"value": serde_json::to_string(&json!({
"nodes": [{
"ref": "obs-1",
"frame_id": "main",
"role": "button",
"name": "Go",
"visible": true,
"enabled": true,
"actions": ["click"]
}],
"forms": [],
"frames": [{"frame_id": "main", "url": "https://example.test/"}],
"focused_ref": "obs-1"
})).unwrap()}}),
None,
);
}
(
json!({"result": {"value": 42}}),
Some(json!({
"method": "Test.event",
"sessionId": session_id,
"params": {"ok": true}
})),
)
}
#[tokio::test]
async fn health_and_capabilities_commands_emit_host_payloads() {
let base = spawn_http_host().await;
health::run(health::Args {
endpoint: base.clone(),
token: Some("token".into()),
})
.await
.unwrap();
capabilities::run(capabilities::Args {
endpoint: base,
token: Some("token".into()),
})
.await
.unwrap();
}
#[tokio::test]
async fn cdp_command_parses_params_waits_and_detaches() {
let endpoint = spawn_cdp().await;
cdp::run(cdp::Args {
method: "Runtime.evaluate".into(),
endpoint,
token: Some("a+b&c%20".into()),
tab: "tab-1".into(),
params: Some(json!({"expression": "1 + 1"}).to_string()),
wait: Some("Test.event:1s".into()),
})
.await
.unwrap();
}
#[tokio::test]
async fn cdp_command_validates_json_and_wait_specs() {
let err = cdp::run(cdp::Args {
method: "Runtime.evaluate".into(),
endpoint: "ws://127.0.0.1:1".into(),
token: None,
tab: "tab-1".into(),
params: Some("{".into()),
wait: None,
})
.await
.err()
.unwrap();
assert_eq!(err.error_code, ErrorCode::InvalidArgument);
let err = cdp::run(cdp::Args {
method: "Runtime.evaluate".into(),
endpoint: "ws://127.0.0.1:1".into(),
token: None,
tab: "tab-1".into(),
params: Some("{}".into()),
wait: Some("missing-timeout".into()),
})
.await
.err()
.unwrap();
assert_eq!(err.error_code, ErrorCode::InvalidArgument);
}
#[tokio::test]
async fn tabs_commands_cover_list_and_close() {
let endpoint = spawn_cdp().await;
tabs::run(tabs::Args {
sub: tabs::TabsSub::List(tabs::EndpointArgs {
endpoint: endpoint.clone(),
token: Some("token".into()),
}),
})
.await
.unwrap();
tabs::run(tabs::Args {
sub: tabs::TabsSub::Close(tabs::CloseArgs {
tab: "tab-1".into(),
endpoint: endpoint.clone(),
token: None,
}),
})
.await
.unwrap();
let err = tabs::run(tabs::Args {
sub: tabs::TabsSub::Close(tabs::CloseArgs {
tab: " ".into(),
endpoint: "ws://127.0.0.1:1".into(),
token: None,
}),
})
.await
.err()
.unwrap();
assert_eq!(err.error_code, ErrorCode::InvalidArgument);
}
#[tokio::test]
async fn upload_command_uses_set_file_input_files() {
let endpoint = spawn_cdp().await;
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("upload.txt");
tokio::fs::write(&file, b"hello").await.unwrap();
upload::run(upload::Args {
endpoint,
token: Some("token".into()),
tab: "tab-1".into(),
selector: "input[type=file]".into(),
file,
})
.await
.unwrap();
}
#[tokio::test]
async fn profile_command_covers_local_lifecycle_branches() {
let tmp = tempfile::tempdir().unwrap();
let profile_dir = tmp.path().join("brave").join("work");
std::fs::create_dir_all(&profile_dir).unwrap();
let meta = crate::sdk::profile::meta::ProfileMeta::new("work", "brave");
std::fs::write(
profile_dir.join("afhttp-profile.json"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
let downloads_dir = profile_dir.join("downloads");
std::fs::create_dir_all(&downloads_dir).unwrap();
std::fs::write(downloads_dir.join("report.csv"), "abc").unwrap();
let old = std::time::SystemTime::now() - Duration::from_secs(7200);
filetime::set_file_mtime(&profile_dir, filetime::FileTime::from_system_time(old)).ok();
profile::run(profile::Args {
sub: profile::ProfileSub::List(profile::ListArgs {
profile_root: Some(tmp.path().to_path_buf()),
}),
})
.await
.unwrap();
profile::run(profile::Args {
sub: profile::ProfileSub::Info(profile::InfoArgs {
name: "work".into(),
backend: Some("brave".into()),
profile_root: Some(tmp.path().to_path_buf()),
}),
})
.await
.unwrap();
profile::run(profile::Args {
sub: profile::ProfileSub::LockStatus(profile::InfoArgs {
name: "work".into(),
backend: Some("brave".into()),
profile_root: Some(tmp.path().to_path_buf()),
}),
})
.await
.unwrap();
profile::run(profile::Args {
sub: profile::ProfileSub::Cookies(profile::InfoArgs {
name: "work".into(),
backend: Some("brave".into()),
profile_root: Some(tmp.path().to_path_buf()),
}),
})
.await
.unwrap();
profile::run(profile::Args {
sub: profile::ProfileSub::Downloads(profile::InfoArgs {
name: "work".into(),
backend: Some("brave".into()),
profile_root: Some(tmp.path().to_path_buf()),
}),
})
.await
.unwrap();
profile::run(profile::Args {
sub: profile::ProfileSub::Prune(profile::PruneArgs {
older_than: "1h".into(),
dry_run: true,
profile_root: Some(tmp.path().to_path_buf()),
}),
})
.await
.unwrap();
profile::run(profile::Args {
sub: profile::ProfileSub::Delete(profile::DeleteArgs {
name: "work".into(),
backend: Some("brave".into()),
confirm: "work".into(),
profile_root: Some(tmp.path().to_path_buf()),
}),
})
.await
.unwrap();
assert!(!profile_dir.exists());
}
}