Skip to main content

agent_first_http/cli/cmd/
mod.rs

1//! Per-subcommand implementations. Each takes the argument struct the CLI
2//! registry projected for it, calls into the SDK, and emits a response
3//! envelope.
4
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 ui;
16pub mod upload;
17
18#[cfg(test)]
19mod tests {
20    use std::time::Duration;
21
22    use axum::routing::get;
23    use futures::{SinkExt, StreamExt};
24    use serde_json::{Value, json};
25    use tokio::net::{TcpListener, TcpStream};
26    use tokio_tungstenite::tungstenite::Message;
27
28    use super::*;
29    use crate::cli::connect::Connection;
30    use crate::shared::error::ErrorCode;
31    use agent_first_data::ValueSource;
32
33    fn ensure_rustls_provider() {
34        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
35    }
36
37    /// A host these tests name outright: every one of them spawns its own
38    /// server, so nothing here may fall back to discovery.
39    fn host_at(endpoint: &str, token: Option<&str>) -> Connection {
40        Connection::new(
41            Some(endpoint.to_string()),
42            token.map(|token| ValueSource::Literal(token.to_string())),
43        )
44    }
45
46    async fn spawn_http_host() -> String {
47        ensure_rustls_provider();
48        let app = axum::Router::new()
49            .route(
50                "/health",
51                get(|| async {
52                    axum::Json(crate::shared::afdata::result_value(
53                        "health",
54                        json!({
55                            "code": "health",
56                            "status": "ok",
57                            "version": env!("CARGO_PKG_VERSION"),
58                            "uptime_s": 1,
59                            "tabs_active": 2,
60                            "capabilities_url": "/capabilities",
61                        }),
62                    ))
63                }),
64            )
65            .route(
66                "/capabilities",
67                get(|| async {
68                    axum::Json(crate::shared::afdata::result_value(
69                        "capabilities",
70                        json!({
71                            "code": "capabilities",
72                            "backend": {"family": "test", "version": "1"},
73                            "artifacts": {
74                                "body": {"supported": true},
75                                "network": {"supported": true, "body_capture": ["xhr"]},
76                                "screenshot": {"supported": false}
77                            },
78                            "wait_modes": ["auto", "load"],
79                            "takeover": {"supported": false, "backend_capable": false},
80                            "profile": {"persistent": true, "ephemeral": true},
81                            "features": {},
82                            "limits": {}
83                        }),
84                    ))
85                }),
86            );
87        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
88        let addr = listener.local_addr().unwrap();
89        tokio::spawn(async move {
90            let _ = axum::serve(listener, app).await;
91        });
92        format!("http://{addr}")
93    }
94
95    async fn spawn_cdp() -> String {
96        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
97        let addr = listener.local_addr().unwrap();
98        tokio::spawn(async move {
99            while let Ok((stream, _)) = listener.accept().await {
100                tokio::spawn(handle_cdp(stream));
101            }
102        });
103        format!("ws://{addr}")
104    }
105
106    async fn handle_cdp(stream: TcpStream) {
107        let Ok(ws) = tokio_tungstenite::accept_async(stream).await else {
108            return;
109        };
110        let (mut tx, mut rx) = ws.split();
111        while let Some(Ok(Message::Text(text))) = rx.next().await {
112            let Ok(value) = serde_json::from_str::<Value>(text.as_str()) else {
113                continue;
114            };
115            let id = value.get("id").and_then(Value::as_i64).unwrap_or(0);
116            let method = value.get("method").and_then(Value::as_str).unwrap_or("");
117            let params = value.get("params").cloned().unwrap_or(Value::Null);
118            let session_id = value
119                .get("sessionId")
120                .and_then(Value::as_str)
121                .unwrap_or("session-1")
122                .to_string();
123            let (result, delayed_event) = cdp_response(method, &params, &session_id);
124            let response = json!({"id": id, "result": result}).to_string();
125            if tx.send(Message::Text(response.into())).await.is_err() {
126                return;
127            }
128            if let Some(event) = delayed_event {
129                tokio::time::sleep(Duration::from_millis(25)).await;
130                let _ = tx.send(Message::Text(event.to_string().into())).await;
131            }
132        }
133    }
134
135    fn cdp_response(method: &str, params: &Value, session_id: &str) -> (Value, Option<Value>) {
136        match method {
137            "Target.getTargets" => (
138                json!({
139                    "targetInfos": [{
140                        "targetId": "tab-1",
141                        "type": "page",
142                        "title": "Example",
143                        "url": "https://example.test/"
144                    }]
145                }),
146                None,
147            ),
148            "Target.attachToTarget" => (json!({"sessionId": "session-1"}), None),
149            "Target.closeTarget" => (json!({"success": true}), None),
150            "Target.detachFromTarget"
151            | "Runtime.enable"
152            | "DOM.enable"
153            | "DOM.setFileInputFiles" => (json!({}), None),
154            "DOM.getDocument" => (json!({"root": {"nodeId": 1}}), None),
155            "DOM.querySelector" => (json!({"nodeId": 2}), None),
156            "DOM.describeNode" => (
157                json!({"node": {"nodeName": "INPUT", "attributes": ["type", "file"]}}),
158                None,
159            ),
160            "Runtime.evaluate" => runtime_evaluate_response(params, session_id),
161            _ => (json!({"ok": true}), None),
162        }
163    }
164
165    fn runtime_evaluate_response(params: &Value, session_id: &str) -> (Value, Option<Value>) {
166        let expression = params
167            .get("expression")
168            .and_then(Value::as_str)
169            .unwrap_or("");
170        if expression == "location.href" {
171            return (json!({"result": {"value": "https://example.test/"}}), None);
172        }
173        if expression.contains("document.title") {
174            return (
175                json!({"result": {"value": "{\"title\":\"Example\",\"w\":800,\"h\":600,\"dpr\":1}"}}),
176                None,
177            );
178        }
179        if expression.contains("AFHTTP_OBSERVATION_SNAPSHOT") {
180            return (
181                json!({"result": {"value": serde_json::to_string(&json!({
182                    "nodes": [{
183                        "ref": "obs-1",
184                        "frame_id": "main",
185                        "role": "button",
186                        "name": "Go",
187                        "visible": true,
188                        "enabled": true,
189                        "actions": ["click"]
190                    }],
191                    "forms": [],
192                    "frames": [{"frame_id": "main", "url": "https://example.test/"}],
193                    "focused_ref": "obs-1"
194                })).unwrap()}}),
195                None,
196            );
197        }
198        (
199            json!({"result": {"value": 42}}),
200            Some(json!({
201                "method": "Test.event",
202                "sessionId": session_id,
203                "params": {"ok": true}
204            })),
205        )
206    }
207
208    #[tokio::test]
209    async fn health_and_capabilities_commands_emit_host_payloads() {
210        let base = spawn_http_host().await;
211
212        health::run(health::Args {
213            connection: host_at(&base, Some("token")),
214        })
215        .await
216        .unwrap();
217
218        capabilities::run(capabilities::Args {
219            connection: host_at(&base, Some("token")),
220        })
221        .await
222        .unwrap();
223    }
224
225    #[tokio::test]
226    async fn cdp_command_parses_params_waits_and_detaches() {
227        let endpoint = spawn_cdp().await;
228
229        cdp::run(cdp::Args {
230            method: "Runtime.evaluate".into(),
231            connection: host_at(&endpoint, Some("a+b&c%20")),
232            tab: "tab-1".into(),
233            params: Some(json!({"expression": "1 + 1"}).to_string()),
234            wait: Some("Test.event:1s".into()),
235        })
236        .await
237        .unwrap();
238    }
239
240    #[tokio::test]
241    async fn cdp_command_validates_json_and_wait_specs() {
242        let err = cdp::run(cdp::Args {
243            method: "Runtime.evaluate".into(),
244            connection: host_at("ws://127.0.0.1:1", None),
245            tab: "tab-1".into(),
246            params: Some("{".into()),
247            wait: None,
248        })
249        .await
250        .err()
251        .unwrap();
252        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
253
254        let err = cdp::run(cdp::Args {
255            method: "Runtime.evaluate".into(),
256            connection: host_at("ws://127.0.0.1:1", None),
257            tab: "tab-1".into(),
258            params: Some("{}".into()),
259            wait: Some("missing-timeout".into()),
260        })
261        .await
262        .err()
263        .unwrap();
264        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
265    }
266
267    #[tokio::test]
268    async fn tabs_commands_cover_list_and_close() {
269        let endpoint = spawn_cdp().await;
270
271        tabs::run(tabs::Args {
272            sub: tabs::TabsSub::List(tabs::EndpointArgs {
273                connection: host_at(&endpoint, Some("token")),
274            }),
275        })
276        .await
277        .unwrap();
278
279        tabs::run(tabs::Args {
280            sub: tabs::TabsSub::Close(tabs::CloseArgs {
281                tab: "tab-1".into(),
282                connection: host_at(&endpoint, None),
283            }),
284        })
285        .await
286        .unwrap();
287
288        let err = tabs::run(tabs::Args {
289            sub: tabs::TabsSub::Close(tabs::CloseArgs {
290                tab: " ".into(),
291                connection: host_at("ws://127.0.0.1:1", None),
292            }),
293        })
294        .await
295        .err()
296        .unwrap();
297        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
298    }
299
300    #[tokio::test]
301    async fn upload_command_uses_set_file_input_files() {
302        let endpoint = spawn_cdp().await;
303        let tmp = tempfile::tempdir().unwrap();
304        let file = tmp.path().join("upload.txt");
305        tokio::fs::write(&file, b"hello").await.unwrap();
306
307        upload::run(upload::Args {
308            connection: host_at(&endpoint, Some("token")),
309            tab: "tab-1".into(),
310            selector: "input[type=file]".into(),
311            file,
312        })
313        .await
314        .unwrap();
315    }
316
317    #[tokio::test]
318    async fn profile_command_covers_local_lifecycle_branches() {
319        let tmp = tempfile::tempdir().unwrap();
320        let profile_dir = tmp.path().join("brave").join("work");
321        std::fs::create_dir_all(&profile_dir).unwrap();
322        let meta = crate::sdk::profile::meta::ProfileMeta::new("work", "brave");
323        std::fs::write(
324            profile_dir.join("afhttp-profile.json"),
325            serde_json::to_string(&meta).unwrap(),
326        )
327        .unwrap();
328        let downloads_dir = profile_dir.join("downloads");
329        std::fs::create_dir_all(&downloads_dir).unwrap();
330        std::fs::write(downloads_dir.join("report.csv"), "abc").unwrap();
331        let old = std::time::SystemTime::now() - Duration::from_secs(7200);
332        filetime::set_file_mtime(&profile_dir, filetime::FileTime::from_system_time(old)).ok();
333
334        profile::run(profile::Args {
335            sub: profile::ProfileSub::List(profile::ListArgs {
336                profile_root: Some(tmp.path().to_path_buf()),
337            }),
338        })
339        .await
340        .unwrap();
341        profile::run(profile::Args {
342            sub: profile::ProfileSub::Info(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::LockStatus(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::Cookies(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::Downloads(profile::InfoArgs {
370                name: "work".into(),
371                backend: Some("brave".into()),
372                profile_root: Some(tmp.path().to_path_buf()),
373            }),
374        })
375        .await
376        .unwrap();
377        profile::run(profile::Args {
378            sub: profile::ProfileSub::Prune(profile::PruneArgs {
379                older_than: "1h".into(),
380                dry_run: true,
381                profile_root: Some(tmp.path().to_path_buf()),
382            }),
383        })
384        .await
385        .unwrap();
386        profile::run(profile::Args {
387            sub: profile::ProfileSub::Delete(profile::DeleteArgs {
388                name: "work".into(),
389                backend: Some("brave".into()),
390                confirm: "work".into(),
391                profile_root: Some(tmp.path().to_path_buf()),
392            }),
393        })
394        .await
395        .unwrap();
396        assert!(!profile_dir.exists());
397    }
398}