Skip to main content

agent_first_http/cli/cmd/
mod.rs

1//! Per-subcommand implementations. Each builds a request from clap args,
2//! calls into the SDK, and emits a response envelope.
3
4pub 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 ui;
14pub mod upload;
15
16#[cfg(test)]
17mod tests {
18    use std::time::Duration;
19
20    use axum::routing::get;
21    use futures::{SinkExt, StreamExt};
22    use serde_json::{json, Value};
23    use tokio::net::{TcpListener, TcpStream};
24    use tokio_tungstenite::tungstenite::Message;
25
26    use super::*;
27    use crate::shared::error::ErrorCode;
28
29    fn ensure_rustls_provider() {
30        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
31    }
32
33    async fn spawn_http_host() -> String {
34        ensure_rustls_provider();
35        let app = axum::Router::new()
36            .route(
37                "/health",
38                get(|| async {
39                    axum::Json(json!({
40                        "code": "health",
41                        "status": "ok",
42                        "version": env!("CARGO_PKG_VERSION"),
43                        "uptime_s": 1,
44                        "tabs_active": 2,
45                        "capabilities_url": "/capabilities",
46                    }))
47                }),
48            )
49            .route(
50                "/capabilities",
51                get(|| async {
52                    axum::Json(json!({
53                        "code": "capabilities",
54                        "backend": {"family": "test", "version": "1"},
55                        "artifacts": {
56                            "body": {"supported": true},
57                            "network": {"supported": true, "body_capture": ["xhr"]},
58                            "screenshot": {"supported": false}
59                        },
60                        "wait_modes": ["load"],
61                        "display_takeover": false,
62                        "ops_panel": {"supported": false, "screencast": 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, &params, &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                id: "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                id: " ".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("work");
312        std::fs::create_dir_all(&profile_dir).unwrap();
313        let meta = crate::sdk::profile::meta::ProfileMeta::new("work");
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                profile_root: Some(tmp.path().to_path_buf()),
336            }),
337        })
338        .await
339        .unwrap();
340        profile::run(profile::Args {
341            sub: profile::ProfileSub::LockStatus(profile::InfoArgs {
342                name: "work".into(),
343                profile_root: Some(tmp.path().to_path_buf()),
344            }),
345        })
346        .await
347        .unwrap();
348        profile::run(profile::Args {
349            sub: profile::ProfileSub::Cookies(profile::InfoArgs {
350                name: "work".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::Downloads(profile::InfoArgs {
358                name: "work".into(),
359                profile_root: Some(tmp.path().to_path_buf()),
360            }),
361        })
362        .await
363        .unwrap();
364        profile::run(profile::Args {
365            sub: profile::ProfileSub::Prune(profile::PruneArgs {
366                older_than: "1h".into(),
367                dry_run: true,
368                profile_root: Some(tmp.path().to_path_buf()),
369            }),
370        })
371        .await
372        .unwrap();
373        profile::run(profile::Args {
374            sub: profile::ProfileSub::Delete(profile::DeleteArgs {
375                name: "work".into(),
376                confirm: "work".into(),
377                profile_root: Some(tmp.path().to_path_buf()),
378            }),
379        })
380        .await
381        .unwrap();
382        assert!(!profile_dir.exists());
383    }
384}