1pub mod capabilities;
6pub mod cdp;
7pub mod container;
8pub mod fetch;
9pub mod health;
10pub mod host;
11pub mod panel;
12pub mod profile;
13pub mod skill;
14pub mod tabs;
15pub mod ui;
16pub mod upload;
17
18#[cfg(test)]
19mod tests {
20 use std::time::Duration;
21
22 use axum::routing::get;
23 use futures::{SinkExt, StreamExt};
24 use serde_json::{Value, json};
25 use tokio::net::{TcpListener, TcpStream};
26 use tokio_tungstenite::tungstenite::Message;
27
28 use super::*;
29 use crate::cli::connect::Connection;
30 use crate::shared::error::ErrorCode;
31 use agent_first_data::ValueSource;
32
33 fn ensure_rustls_provider() {
34 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
35 }
36
37 fn host_at(endpoint: &str, token: Option<&str>) -> Connection {
40 Connection::new(
41 Some(endpoint.to_string()),
42 token.map(|token| ValueSource::Literal(token.to_string())),
43 )
44 }
45
46 async fn spawn_http_host() -> String {
47 ensure_rustls_provider();
48 let app = axum::Router::new()
49 .route(
50 "/health",
51 get(|| async {
52 axum::Json(crate::shared::afdata::result_value(
53 "health",
54 json!({
55 "code": "health",
56 "status": "ok",
57 "version": env!("CARGO_PKG_VERSION"),
58 "uptime_s": 1,
59 "tabs_active": 2,
60 "capabilities_url": "/capabilities",
61 }),
62 ))
63 }),
64 )
65 .route(
66 "/capabilities",
67 get(|| async {
68 axum::Json(crate::shared::afdata::result_value(
69 "capabilities",
70 json!({
71 "code": "capabilities",
72 "backend": {"family": "test", "version": "1"},
73 "artifacts": {
74 "body": {"supported": true},
75 "network": {"supported": true, "body_capture": ["xhr"]},
76 "screenshot": {"supported": false}
77 },
78 "wait_modes": ["auto", "load"],
79 "takeover": {"supported": false, "backend_capable": false},
80 "profile": {"persistent": true, "ephemeral": true},
81 "features": {},
82 "limits": {}
83 }),
84 ))
85 }),
86 );
87 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
88 let addr = listener.local_addr().unwrap();
89 tokio::spawn(async move {
90 let _ = axum::serve(listener, app).await;
91 });
92 format!("http://{addr}")
93 }
94
95 async fn spawn_cdp() -> String {
96 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
97 let addr = listener.local_addr().unwrap();
98 tokio::spawn(async move {
99 while let Ok((stream, _)) = listener.accept().await {
100 tokio::spawn(handle_cdp(stream));
101 }
102 });
103 format!("ws://{addr}")
104 }
105
106 async fn handle_cdp(stream: TcpStream) {
107 let Ok(ws) = tokio_tungstenite::accept_async(stream).await else {
108 return;
109 };
110 let (mut tx, mut rx) = ws.split();
111 while let Some(Ok(Message::Text(text))) = rx.next().await {
112 let Ok(value) = serde_json::from_str::<Value>(text.as_str()) else {
113 continue;
114 };
115 let id = value.get("id").and_then(Value::as_i64).unwrap_or(0);
116 let method = value.get("method").and_then(Value::as_str).unwrap_or("");
117 let params = value.get("params").cloned().unwrap_or(Value::Null);
118 let session_id = value
119 .get("sessionId")
120 .and_then(Value::as_str)
121 .unwrap_or("session-1")
122 .to_string();
123 let (result, delayed_event) = cdp_response(method, ¶ms, &session_id);
124 let response = json!({"id": id, "result": result}).to_string();
125 if tx.send(Message::Text(response.into())).await.is_err() {
126 return;
127 }
128 if let Some(event) = delayed_event {
129 tokio::time::sleep(Duration::from_millis(25)).await;
130 let _ = tx.send(Message::Text(event.to_string().into())).await;
131 }
132 }
133 }
134
135 fn cdp_response(method: &str, params: &Value, session_id: &str) -> (Value, Option<Value>) {
136 match method {
137 "Target.getTargets" => (
138 json!({
139 "targetInfos": [{
140 "targetId": "tab-1",
141 "type": "page",
142 "title": "Example",
143 "url": "https://example.test/"
144 }]
145 }),
146 None,
147 ),
148 "Target.attachToTarget" => (json!({"sessionId": "session-1"}), None),
149 "Target.closeTarget" => (json!({"success": true}), None),
150 "Target.detachFromTarget"
151 | "Runtime.enable"
152 | "DOM.enable"
153 | "DOM.setFileInputFiles" => (json!({}), None),
154 "DOM.getDocument" => (json!({"root": {"nodeId": 1}}), None),
155 "DOM.querySelector" => (json!({"nodeId": 2}), None),
156 "DOM.describeNode" => (
157 json!({"node": {"nodeName": "INPUT", "attributes": ["type", "file"]}}),
158 None,
159 ),
160 "Runtime.evaluate" => runtime_evaluate_response(params, session_id),
161 _ => (json!({"ok": true}), None),
162 }
163 }
164
165 fn runtime_evaluate_response(params: &Value, session_id: &str) -> (Value, Option<Value>) {
166 let expression = params
167 .get("expression")
168 .and_then(Value::as_str)
169 .unwrap_or("");
170 if expression == "location.href" {
171 return (json!({"result": {"value": "https://example.test/"}}), None);
172 }
173 if expression.contains("document.title") {
174 return (
175 json!({"result": {"value": "{\"title\":\"Example\",\"w\":800,\"h\":600,\"dpr\":1}"}}),
176 None,
177 );
178 }
179 if expression.contains("AFHTTP_OBSERVATION_SNAPSHOT") {
180 return (
181 json!({"result": {"value": serde_json::to_string(&json!({
182 "nodes": [{
183 "ref": "obs-1",
184 "frame_id": "main",
185 "role": "button",
186 "name": "Go",
187 "visible": true,
188 "enabled": true,
189 "actions": ["click"]
190 }],
191 "forms": [],
192 "frames": [{"frame_id": "main", "url": "https://example.test/"}],
193 "focused_ref": "obs-1"
194 })).unwrap()}}),
195 None,
196 );
197 }
198 (
199 json!({"result": {"value": 42}}),
200 Some(json!({
201 "method": "Test.event",
202 "sessionId": session_id,
203 "params": {"ok": true}
204 })),
205 )
206 }
207
208 #[tokio::test]
209 async fn health_and_capabilities_commands_emit_host_payloads() {
210 let base = spawn_http_host().await;
211
212 health::run(health::Args {
213 connection: host_at(&base, Some("token")),
214 })
215 .await
216 .unwrap();
217
218 capabilities::run(capabilities::Args {
219 connection: host_at(&base, Some("token")),
220 })
221 .await
222 .unwrap();
223 }
224
225 #[tokio::test]
226 async fn cdp_command_parses_params_waits_and_detaches() {
227 let endpoint = spawn_cdp().await;
228
229 cdp::run(cdp::Args {
230 method: "Runtime.evaluate".into(),
231 connection: host_at(&endpoint, Some("a+b&c%20")),
232 tab: "tab-1".into(),
233 params: Some(json!({"expression": "1 + 1"}).to_string()),
234 wait: Some("Test.event:1s".into()),
235 })
236 .await
237 .unwrap();
238 }
239
240 #[tokio::test]
241 async fn cdp_command_validates_json_and_wait_specs() {
242 let err = cdp::run(cdp::Args {
243 method: "Runtime.evaluate".into(),
244 connection: host_at("ws://127.0.0.1:1", None),
245 tab: "tab-1".into(),
246 params: Some("{".into()),
247 wait: None,
248 })
249 .await
250 .err()
251 .unwrap();
252 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
253
254 let err = cdp::run(cdp::Args {
255 method: "Runtime.evaluate".into(),
256 connection: host_at("ws://127.0.0.1:1", None),
257 tab: "tab-1".into(),
258 params: Some("{}".into()),
259 wait: Some("missing-timeout".into()),
260 })
261 .await
262 .err()
263 .unwrap();
264 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
265 }
266
267 #[tokio::test]
268 async fn tabs_commands_cover_list_and_close() {
269 let endpoint = spawn_cdp().await;
270
271 tabs::run(tabs::Args {
272 sub: tabs::TabsSub::List(tabs::EndpointArgs {
273 connection: host_at(&endpoint, Some("token")),
274 }),
275 })
276 .await
277 .unwrap();
278
279 tabs::run(tabs::Args {
280 sub: tabs::TabsSub::Close(tabs::CloseArgs {
281 tab: "tab-1".into(),
282 connection: host_at(&endpoint, None),
283 }),
284 })
285 .await
286 .unwrap();
287
288 let err = tabs::run(tabs::Args {
289 sub: tabs::TabsSub::Close(tabs::CloseArgs {
290 tab: " ".into(),
291 connection: host_at("ws://127.0.0.1:1", None),
292 }),
293 })
294 .await
295 .err()
296 .unwrap();
297 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
298 }
299
300 #[tokio::test]
301 async fn upload_command_uses_set_file_input_files() {
302 let endpoint = spawn_cdp().await;
303 let tmp = tempfile::tempdir().unwrap();
304 let file = tmp.path().join("upload.txt");
305 tokio::fs::write(&file, b"hello").await.unwrap();
306
307 upload::run(upload::Args {
308 connection: host_at(&endpoint, Some("token")),
309 tab: "tab-1".into(),
310 selector: "input[type=file]".into(),
311 file,
312 })
313 .await
314 .unwrap();
315 }
316
317 #[tokio::test]
318 async fn profile_command_covers_local_lifecycle_branches() {
319 let tmp = tempfile::tempdir().unwrap();
320 let profile_dir = tmp.path().join("brave").join("work");
321 std::fs::create_dir_all(&profile_dir).unwrap();
322 let meta = crate::sdk::profile::meta::ProfileMeta::new("work", "brave");
323 std::fs::write(
324 profile_dir.join("afhttp-profile.json"),
325 serde_json::to_string(&meta).unwrap(),
326 )
327 .unwrap();
328 let downloads_dir = profile_dir.join("downloads");
329 std::fs::create_dir_all(&downloads_dir).unwrap();
330 std::fs::write(downloads_dir.join("report.csv"), "abc").unwrap();
331 let old = std::time::SystemTime::now() - Duration::from_secs(7200);
332 filetime::set_file_mtime(&profile_dir, filetime::FileTime::from_system_time(old)).ok();
333
334 profile::run(profile::Args {
335 sub: profile::ProfileSub::List(profile::ListArgs {
336 profile_root: Some(tmp.path().to_path_buf()),
337 }),
338 })
339 .await
340 .unwrap();
341 profile::run(profile::Args {
342 sub: profile::ProfileSub::Info(profile::InfoArgs {
343 name: "work".into(),
344 backend: Some("brave".into()),
345 profile_root: Some(tmp.path().to_path_buf()),
346 }),
347 })
348 .await
349 .unwrap();
350 profile::run(profile::Args {
351 sub: profile::ProfileSub::LockStatus(profile::InfoArgs {
352 name: "work".into(),
353 backend: Some("brave".into()),
354 profile_root: Some(tmp.path().to_path_buf()),
355 }),
356 })
357 .await
358 .unwrap();
359 profile::run(profile::Args {
360 sub: profile::ProfileSub::Cookies(profile::InfoArgs {
361 name: "work".into(),
362 backend: Some("brave".into()),
363 profile_root: Some(tmp.path().to_path_buf()),
364 }),
365 })
366 .await
367 .unwrap();
368 profile::run(profile::Args {
369 sub: profile::ProfileSub::Downloads(profile::InfoArgs {
370 name: "work".into(),
371 backend: Some("brave".into()),
372 profile_root: Some(tmp.path().to_path_buf()),
373 }),
374 })
375 .await
376 .unwrap();
377 profile::run(profile::Args {
378 sub: profile::ProfileSub::Prune(profile::PruneArgs {
379 older_than: "1h".into(),
380 dry_run: true,
381 profile_root: Some(tmp.path().to_path_buf()),
382 }),
383 })
384 .await
385 .unwrap();
386 profile::run(profile::Args {
387 sub: profile::ProfileSub::Delete(profile::DeleteArgs {
388 name: "work".into(),
389 backend: Some("brave".into()),
390 confirm: "work".into(),
391 profile_root: Some(tmp.path().to_path_buf()),
392 }),
393 })
394 .await
395 .unwrap();
396 assert!(!profile_dir.exists());
397 }
398}