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