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