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