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