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 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, &params, &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}