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::{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, &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                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}