diff --git a/crates/reach-cli/src/commands/connect.rs b/crates/reach-cli/src/commands/connect.rs index a57f6e7..2e284a3 100644 --- a/crates/reach-cli/src/commands/connect.rs +++ b/crates/reach-cli/src/commands/connect.rs @@ -1,9 +1,8 @@ +use clap::Args; use reach_cli::docker::DockerClient; use reach_cli::mcp::{ - JsonRpcRequest, JsonRpcResponse, McpInitializeResult, RequestId, ToolResponse, - tool_definitions, + JsonRpcRequest, JsonRpcResponse, McpInitializeResult, RequestId, ToolResponse, tool_definitions, }; -use clap::Args; use std::io::{BufRead, Write}; #[derive(Args)] @@ -63,7 +62,11 @@ async fn handle_request( JsonRpcResponse::success(req.id.clone(), serde_json::json!({ "tools": tools })) } "tools/call" => { - let tool_name = req.params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let tool_name = req + .params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); let arguments = req.params.get("arguments").cloned().unwrap_or_default(); dispatch_tool(docker, target, req, tool_name, &arguments).await } @@ -102,7 +105,12 @@ async fn dispatch_tool( Some("middle") => "2", _ => "1", }; - exec_cmd(docker, target, &format!("xdotool mousemove {x} {y} click {btn}")).await + exec_cmd( + docker, + target, + &format!("xdotool mousemove {x} {y} click {btn}"), + ) + .await } "type" => { let text = args.get("text").and_then(|v| v.as_str()).unwrap_or(""); @@ -114,11 +122,17 @@ async fn dispatch_tool( .await } "key" => { - let combo = args.get("combo").and_then(|v| v.as_str()).unwrap_or("Return"); + let combo = args + .get("combo") + .and_then(|v| v.as_str()) + .unwrap_or("Return"); exec_cmd(docker, target, &format!("xdotool key {combo}")).await } "browse" => { - let url = args.get("url").and_then(|v| v.as_str()).unwrap_or("about:blank"); + let url = args + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or("about:blank"); exec_cmd( docker, target, @@ -131,9 +145,19 @@ async fn dispatch_tool( } "scrape" => { let url = args.get("url").and_then(|v| v.as_str()).unwrap_or(""); - let selector = args.get("selector").and_then(|v| v.as_str()).unwrap_or("body"); - let stealth = args.get("stealth").and_then(|v| v.as_bool()).unwrap_or(true); - let fetcher = if stealth { "StealthyFetcher" } else { "Fetcher" }; + let selector = args + .get("selector") + .and_then(|v| v.as_str()) + .unwrap_or("body"); + let stealth = args + .get("stealth") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let fetcher = if stealth { + "StealthyFetcher" + } else { + "Fetcher" + }; let script = format!( "from scrapling import {fetcher}; r = {fetcher}().get('{url}'); \ elems = r.css('{selector}'); \ @@ -146,7 +170,10 @@ async fn dispatch_tool( exec_python(docker, target, script).await } "exec" => { - let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("echo"); + let cmd = args + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or("echo"); exec_cmd(docker, target, cmd).await } _ => ToolResponse::error(format!("unknown tool: {tool}")), @@ -160,9 +187,11 @@ async fn exec_cmd(docker: &DockerClient, target: &str, cmd: &str) -> ToolRespons .exec(target, &["bash".into(), "-c".into(), cmd.into()]) .await { - Ok(out) if out.exit_code == 0 => { - ToolResponse::text(if out.stdout.is_empty() { "ok".into() } else { out.stdout }) - } + Ok(out) if out.exit_code == 0 => ToolResponse::text(if out.stdout.is_empty() { + "ok".into() + } else { + out.stdout + }), Ok(out) => ToolResponse::error(format!("exit {}: {}", out.exit_code, out.stderr)), Err(e) => ToolResponse::error(e.to_string()), } diff --git a/crates/reach-cli/src/commands/create.rs b/crates/reach-cli/src/commands/create.rs index 1b3ffb4..2c09b29 100644 --- a/crates/reach-cli/src/commands/create.rs +++ b/crates/reach-cli/src/commands/create.rs @@ -1,7 +1,7 @@ -use reach_cli::config::ReachConfig; -use reach_cli::docker::{DockerClient, Resolution, SandboxConfig, SandboxPorts}; use clap::Args; use colored::Colorize; +use reach_cli::config::ReachConfig; +use reach_cli::docker::{DockerClient, Resolution, SandboxConfig, SandboxPorts}; use std::time::Duration; #[derive(Args)] diff --git a/crates/reach-cli/src/commands/destroy.rs b/crates/reach-cli/src/commands/destroy.rs index 87611de..4488f6b 100644 --- a/crates/reach-cli/src/commands/destroy.rs +++ b/crates/reach-cli/src/commands/destroy.rs @@ -1,6 +1,6 @@ -use reach_cli::docker::DockerClient; use clap::Args; use colored::Colorize; +use reach_cli::docker::DockerClient; #[derive(Args)] pub struct DestroyArgs { diff --git a/crates/reach-cli/src/commands/exec.rs b/crates/reach-cli/src/commands/exec.rs index 8d4a0a8..16cdea3 100644 --- a/crates/reach-cli/src/commands/exec.rs +++ b/crates/reach-cli/src/commands/exec.rs @@ -1,5 +1,5 @@ -use reach_cli::docker::DockerClient; use clap::Args; +use reach_cli::docker::DockerClient; #[derive(Args)] pub struct ExecArgs { diff --git a/crates/reach-cli/src/commands/list.rs b/crates/reach-cli/src/commands/list.rs index 18784f3..e9bfcd3 100644 --- a/crates/reach-cli/src/commands/list.rs +++ b/crates/reach-cli/src/commands/list.rs @@ -1,5 +1,5 @@ -use reach_cli::docker::{DockerClient, SandboxStatus}; use colored::Colorize; +use reach_cli::docker::{DockerClient, SandboxStatus}; pub async fn run() -> anyhow::Result<()> { let docker = DockerClient::new()?; diff --git a/crates/reach-cli/src/commands/screenshot.rs b/crates/reach-cli/src/commands/screenshot.rs index ad3d6bf..f532a8b 100644 --- a/crates/reach-cli/src/commands/screenshot.rs +++ b/crates/reach-cli/src/commands/screenshot.rs @@ -1,6 +1,6 @@ -use reach_cli::docker::DockerClient; use clap::Args; use colored::Colorize; +use reach_cli::docker::DockerClient; use std::io::Write; #[derive(Args)] diff --git a/crates/reach-cli/src/commands/serve.rs b/crates/reach-cli/src/commands/serve.rs index 34a9c5a..e076841 100644 --- a/crates/reach-cli/src/commands/serve.rs +++ b/crates/reach-cli/src/commands/serve.rs @@ -1,12 +1,12 @@ -use reach_cli::docker::DockerClient; -use reach_cli::mcp::{ - JsonRpcRequest, JsonRpcResponse, McpInitializeResult, ToolResponse, tool_definitions, -}; use axum::extract::State; use axum::response::sse::{Event, Sse}; use axum::routing::{get, post}; use axum::{Json, Router}; use clap::Args; +use reach_cli::docker::DockerClient; +use reach_cli::mcp::{ + JsonRpcRequest, JsonRpcResponse, McpInitializeResult, ToolResponse, tool_definitions, +}; use std::convert::Infallible; use std::sync::Arc; @@ -74,9 +74,7 @@ async fn mcp_handler( } async fn sse_handler() -> Sse>> { - let stream = tokio_stream::once(Ok( - Event::default().event("endpoint").data("/mcp"), - )); + let stream = tokio_stream::once(Ok(Event::default().event("endpoint").data("/mcp"))); Sse::new(stream) } @@ -91,7 +89,11 @@ async fn handle_mcp(state: &AppState, req: &JsonRpcRequest) -> JsonRpcResponse { JsonRpcResponse::success(req.id.clone(), serde_json::json!({ "tools": tools })) } "tools/call" => { - let tool = req.params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let tool = req + .params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); let args = req.params.get("arguments").cloned().unwrap_or_default(); let sandbox_arg = args.get("sandbox").and_then(|v| v.as_str()); @@ -144,19 +146,34 @@ async fn dispatch( Some("middle") => "2", _ => "1", }; - sh(state, target, &format!("xdotool mousemove {x} {y} click {btn}")).await + sh( + state, + target, + &format!("xdotool mousemove {x} {y} click {btn}"), + ) + .await } "type" => { let text = args.get("text").and_then(|v| v.as_str()).unwrap_or(""); - sh(state, target, &format!("xdotool type -- '{}'", text.replace('\'', "'\\''"))) - .await + sh( + state, + target, + &format!("xdotool type -- '{}'", text.replace('\'', "'\\''")), + ) + .await } "key" => { - let combo = args.get("combo").and_then(|v| v.as_str()).unwrap_or("Return"); + let combo = args + .get("combo") + .and_then(|v| v.as_str()) + .unwrap_or("Return"); sh(state, target, &format!("xdotool key {combo}")).await } "browse" => { - let url = args.get("url").and_then(|v| v.as_str()).unwrap_or("about:blank"); + let url = args + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or("about:blank"); sh( state, target, @@ -169,9 +186,19 @@ async fn dispatch( } "scrape" => { let url = args.get("url").and_then(|v| v.as_str()).unwrap_or(""); - let sel = args.get("selector").and_then(|v| v.as_str()).unwrap_or("body"); - let stealth = args.get("stealth").and_then(|v| v.as_bool()).unwrap_or(true); - let f = if stealth { "StealthyFetcher" } else { "Fetcher" }; + let sel = args + .get("selector") + .and_then(|v| v.as_str()) + .unwrap_or("body"); + let stealth = args + .get("stealth") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let f = if stealth { + "StealthyFetcher" + } else { + "Fetcher" + }; py( state, target, @@ -188,7 +215,10 @@ async fn dispatch( py(state, target, script).await } "exec" => { - let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("echo"); + let cmd = args + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or("echo"); sh(state, target, cmd).await } _ => ToolResponse::error(format!("unknown tool: {tool}")), @@ -201,9 +231,11 @@ async fn sh(state: &AppState, target: &str, cmd: &str) -> ToolResponse { .exec(target, &["bash".into(), "-c".into(), cmd.into()]) .await { - Ok(out) if out.exit_code == 0 => { - ToolResponse::text(if out.stdout.is_empty() { "ok".into() } else { out.stdout }) - } + Ok(out) if out.exit_code == 0 => ToolResponse::text(if out.stdout.is_empty() { + "ok".into() + } else { + out.stdout + }), Ok(out) => ToolResponse::error(format!("exit {}: {}", out.exit_code, out.stderr)), Err(e) => ToolResponse::error(e.to_string()), } diff --git a/crates/reach-cli/src/commands/vnc.rs b/crates/reach-cli/src/commands/vnc.rs index 596d7e5..ba9f128 100644 --- a/crates/reach-cli/src/commands/vnc.rs +++ b/crates/reach-cli/src/commands/vnc.rs @@ -1,6 +1,6 @@ -use reach_cli::docker::DockerClient; use clap::Args; use colored::Colorize; +use reach_cli::docker::DockerClient; #[derive(Args)] pub struct VncArgs { diff --git a/crates/reach-cli/src/config.rs b/crates/reach-cli/src/config.rs index 9497c80..fdd00c5 100644 --- a/crates/reach-cli/src/config.rs +++ b/crates/reach-cli/src/config.rs @@ -52,7 +52,6 @@ pub struct DockerConfig { // Defaults // ═══════════════════════════════════════════════════════════ - impl Default for SandboxDefaults { fn default() -> Self { Self { @@ -75,7 +74,6 @@ impl Default for ServerConfig { } } - // ═══════════════════════════════════════════════════════════ // Loading // ═══════════════════════════════════════════════════════════ diff --git a/crates/reach-cli/src/docker.rs b/crates/reach-cli/src/docker.rs index 1e9129b..94c121b 100644 --- a/crates/reach-cli/src/docker.rs +++ b/crates/reach-cli/src/docker.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result, bail}; use bollard::container::{ - Config, CreateContainerOptions, ListContainersOptions, - RemoveContainerOptions, StopContainerOptions, + Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions, + StopContainerOptions, }; use bollard::exec::{CreateExecOptions, StartExecResults}; use bollard::models::{HostConfig, PortBinding}; @@ -261,10 +261,7 @@ impl DockerClient { let sandbox = self.find(target).await?; self.client - .stop_container( - &sandbox.container_id, - Some(StopContainerOptions { t: 10 }), - ) + .stop_container(&sandbox.container_id, Some(StopContainerOptions { t: 10 })) .await .context("failed to stop container")?; @@ -314,10 +311,7 @@ impl DockerClient { status, image: c.image.unwrap_or_default(), ports, - created_at: labels - .get(Labels::CREATED) - .cloned() - .unwrap_or_default(), + created_at: labels.get(Labels::CREATED).cloned().unwrap_or_default(), } }) .collect(); @@ -423,9 +417,10 @@ impl DockerClient { .await; if let Ok(result) = out - && result.exit_code == 0 { - return Ok(()); - } + && result.exit_code == 0 + { + return Ok(()); + } tokio::time::sleep(Duration::from_millis(500)).await; } diff --git a/crates/reach-cli/src/mcp.rs b/crates/reach-cli/src/mcp.rs index 8c66830..f573445 100644 --- a/crates/reach-cli/src/mcp.rs +++ b/crates/reach-cli/src/mcp.rs @@ -227,7 +227,9 @@ pub enum ExtractMode { Html, /// Extract a specific attribute value #[serde(rename = "attr")] - Attribute { name: String }, + Attribute { + name: String, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/reach-cli/tests/docker_types.rs b/crates/reach-cli/tests/docker_types.rs index 2d9a8da..872e53d 100644 --- a/crates/reach-cli/tests/docker_types.rs +++ b/crates/reach-cli/tests/docker_types.rs @@ -87,7 +87,11 @@ fn labels_for_sandbox_includes_all_required_keys() { fn labels_filter_targets_managed_containers() { let filter = Labels::filter(); let label_filters = filter.get("label").unwrap(); - assert!(label_filters.iter().any(|f| f.contains("reach.sandbox=true"))); + assert!( + label_filters + .iter() + .any(|f| f.contains("reach.sandbox=true")) + ); } // ═══════════════════════════════════════════════════════════ diff --git a/crates/reach-cli/tests/e2e_container.rs b/crates/reach-cli/tests/e2e_container.rs index 7bfe815..3124a4a 100644 --- a/crates/reach-cli/tests/e2e_container.rs +++ b/crates/reach-cli/tests/e2e_container.rs @@ -22,20 +22,38 @@ fn ensure_container() { INIT.call_once(|| { let _ = docker(&["rm", "-f", CONTAINER]); let out = docker(&[ - "run", "-d", "--name", CONTAINER, "--shm-size=2g", - "-p", "15900:5900", "-p", "16080:6080", "-p", "18400:8400", + "run", + "-d", + "--name", + CONTAINER, + "--shm-size=2g", + "-p", + "15900:5900", + "-p", + "16080:6080", + "-p", + "18400:8400", IMAGE, ]); assert!(out.status.success(), "start failed: {}", stderr(&out)); - assert!(wait_for_health(30), "never healthy. logs:\n{}", docker_out(&["logs", "--tail", "50", CONTAINER])); + assert!( + wait_for_health(30), + "never healthy. logs:\n{}", + docker_out(&["logs", "--tail", "50", CONTAINER]) + ); }); } fn docker(args: &[&str]) -> std::process::Output { - Command::new("docker").args(args).output().expect("docker not found") + Command::new("docker") + .args(args) + .output() + .expect("docker not found") } fn docker_out(args: &[&str]) -> String { - String::from_utf8_lossy(&docker(args).stdout).trim().to_string() + String::from_utf8_lossy(&docker(args).stdout) + .trim() + .to_string() } fn stderr(o: &std::process::Output) -> String { String::from_utf8_lossy(&o.stderr).trim().to_string() @@ -43,7 +61,12 @@ fn stderr(o: &std::process::Output) -> String { fn wait_for_health(secs: u64) -> bool { let end = std::time::Instant::now() + Duration::from_secs(secs); while std::time::Instant::now() < end { - if Command::new("curl").args(["-sf", HEALTH_URL]).output().map(|o| o.status.success()).unwrap_or(false) { + if Command::new("curl") + .args(["-sf", HEALTH_URL]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { return true; } std::thread::sleep(Duration::from_millis(500)); @@ -51,7 +74,14 @@ fn wait_for_health(secs: u64) -> bool { false } fn curl(url: &str) -> String { - String::from_utf8_lossy(&Command::new("curl").args(["-sf", url]).output().unwrap().stdout).to_string() + String::from_utf8_lossy( + &Command::new("curl") + .args(["-sf", url]) + .output() + .unwrap() + .stdout, + ) + .to_string() } fn curl_json(url: &str) -> serde_json::Value { serde_json::from_str(&curl(url)).unwrap_or_else(|e| panic!("bad json from {url}: {e}")) @@ -64,14 +94,20 @@ fn sh_ok(cmd: &str) -> String { assert!(o.status.success(), "failed: {cmd}\n{}", stderr(&o)); String::from_utf8_lossy(&o.stdout).trim().to_string() } -fn sh_code(cmd: &str) -> i32 { sh(cmd).status.code().unwrap_or(-1) } -fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } +fn sh_code(cmd: &str) -> i32 { + sh(cmd).status.code().unwrap_or(-1) +} +fn sleep_ms(ms: u64) { + std::thread::sleep(Duration::from_millis(ms)); +} // ═══════════════════════════════════════════════════════════ // 1. SUPERVISOR // ═══════════════════════════════════════════════════════════ -#[test] #[ignore] fn t01_health_json() { +#[test] +#[ignore] +fn t01_health_json() { ensure_container(); let h = curl_json(HEALTH_URL); assert_eq!(h["service"], "reach-supervisor"); @@ -79,29 +115,50 @@ fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } assert!(h["display"].as_str().unwrap().starts_with(':')); } -#[test] #[ignore] fn t02_four_processes_running() { +#[test] +#[ignore] +fn t02_four_processes_running() { ensure_container(); let h = curl_json(HEALTH_URL); let procs = h["processes"].as_array().unwrap(); assert_eq!(procs.len(), 4); for name in ["xvfb", "openbox", "x11vnc", "novnc"] { - let p = procs.iter().find(|p| p["name"] == name).unwrap_or_else(|| panic!("missing {name}")); + let p = procs + .iter() + .find(|p| p["name"] == name) + .unwrap_or_else(|| panic!("missing {name}")); assert_eq!(p["status"], "running", "{name} not running"); assert!(p["pid"].as_u64().unwrap() > 0); assert_eq!(p["restart_count"], 0, "{name} restarted"); } } -#[test] #[ignore] fn t03_healthy_status() { +#[test] +#[ignore] +fn t03_healthy_status() { ensure_container(); assert_eq!(curl_json(HEALTH_URL)["status"], "healthy"); } -#[test] #[ignore] fn t04_metrics_200() { +#[test] +#[ignore] +fn t04_metrics_200() { ensure_container(); let code = String::from_utf8_lossy( - &Command::new("curl").args(["-sf", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:18400/metrics"]).output().unwrap().stdout - ).to_string(); + &Command::new("curl") + .args([ + "-sf", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "http://localhost:18400/metrics", + ]) + .output() + .unwrap() + .stdout, + ) + .to_string(); assert_eq!(code.trim(), "200"); } @@ -109,20 +166,28 @@ fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } // 2. DISPLAY // ═══════════════════════════════════════════════════════════ -#[test] #[ignore] fn t05_x11_socket() { +#[test] +#[ignore] +fn t05_x11_socket() { ensure_container(); assert_eq!(sh_code("test -S /tmp/.X11-unix/X99"), 0); } -#[test] #[ignore] fn t06_resolution() { +#[test] +#[ignore] +fn t06_resolution() { ensure_container(); assert_eq!(sh_ok("DISPLAY=:99 xdotool getdisplaygeometry"), "1280 720"); } -#[test] #[ignore] fn t07_24bit_color() { +#[test] +#[ignore] +fn t07_24bit_color() { ensure_container(); // xdpyinfo may not be installed; use xrandr or python to check depth - let depth = sh_ok("DISPLAY=:99 python3 -c \"import subprocess; o=subprocess.check_output(['xdotool','getdisplaygeometry']).decode(); print('24')\""); + let depth = sh_ok( + "DISPLAY=:99 python3 -c \"import subprocess; o=subprocess.check_output(['xdotool','getdisplaygeometry']).decode(); print('24')\"", + ); assert!(depth.contains("24")); } @@ -130,12 +195,19 @@ fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } // 3. VNC // ═══════════════════════════════════════════════════════════ -#[test] #[ignore] fn t08_vnc_port() { +#[test] +#[ignore] +fn t08_vnc_port() { ensure_container(); - assert_eq!(sh_code("timeout 2 bash -c 'echo > /dev/tcp/localhost/5900'"), 0); + assert_eq!( + sh_code("timeout 2 bash -c 'echo > /dev/tcp/localhost/5900'"), + 0 + ); } -#[test] #[ignore] fn t09_novnc_html() { +#[test] +#[ignore] +fn t09_novnc_html() { ensure_container(); let body = curl(NOVNC_URL); assert!(body.contains("html") || body.contains("noVNC") || body.contains("Directory")); @@ -145,16 +217,22 @@ fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } // 4. SCREENSHOT // ═══════════════════════════════════════════════════════════ -#[test] #[ignore] fn t10_png_magic_bytes() { +#[test] +#[ignore] +fn t10_png_magic_bytes() { ensure_container(); sh_ok("DISPLAY=:99 scrot -z /tmp/e2e_shot.png"); assert_eq!(sh_ok("od -A n -t x1 -N 4 /tmp/e2e_shot.png"), "89 50 4e 47"); } -#[test] #[ignore] fn t11_png_dimensions() { +#[test] +#[ignore] +fn t11_png_dimensions() { ensure_container(); sh_ok("DISPLAY=:99 scrot -z /tmp/e2e_dim.png"); - let dims = sh_ok("python3 -c \"import struct; f=open('/tmp/e2e_dim.png','rb'); f.read(16); w,h=struct.unpack('>II',f.read(8)); print(f'{w}x{h}')\""); + let dims = sh_ok( + "python3 -c \"import struct; f=open('/tmp/e2e_dim.png','rb'); f.read(16); w,h=struct.unpack('>II',f.read(8)); print(f'{w}x{h}')\"", + ); assert_eq!(dims, "1280x720"); } @@ -162,24 +240,35 @@ fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } // 5. INPUT // ═══════════════════════════════════════════════════════════ -#[test] #[ignore] fn t12_mouse_move() { +#[test] +#[ignore] +fn t12_mouse_move() { ensure_container(); sh_ok("DISPLAY=:99 xdotool mousemove 100 200"); let pos = sh_ok("DISPLAY=:99 xdotool getmouselocation"); - assert!(pos.contains("x:100") && pos.contains("y:200"), "bad pos: {pos}"); + assert!( + pos.contains("x:100") && pos.contains("y:200"), + "bad pos: {pos}" + ); } -#[test] #[ignore] fn t13_mouse_click() { +#[test] +#[ignore] +fn t13_mouse_click() { ensure_container(); sh_ok("DISPLAY=:99 xdotool mousemove 640 360 click 1"); } -#[test] #[ignore] fn t14_keyboard_type() { +#[test] +#[ignore] +fn t14_keyboard_type() { ensure_container(); sh_ok("DISPLAY=:99 xdotool type 'reach e2e'"); } -#[test] #[ignore] fn t15_key_combo() { +#[test] +#[ignore] +fn t15_key_combo() { ensure_container(); sh_ok("DISPLAY=:99 xdotool key ctrl+l"); } @@ -188,31 +277,48 @@ fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } // 6. CHROME // ═══════════════════════════════════════════════════════════ -#[test] #[ignore] fn t16_chrome_version() { +#[test] +#[ignore] +fn t16_chrome_version() { ensure_container(); assert!(sh_ok("google-chrome --version").contains("Google Chrome")); } -#[test] #[ignore] fn t17_chrome_headed() { +#[test] +#[ignore] +fn t17_chrome_headed() { ensure_container(); - let _ = sh("pkill -f chrome"); sleep_ms(500); - sh_ok("DISPLAY=:99 google-chrome --no-sandbox --disable-gpu --no-first-run --window-size=1280,720 https://example.com &"); + let _ = sh("pkill -f chrome"); + sleep_ms(500); + sh_ok( + "DISPLAY=:99 google-chrome --no-sandbox --disable-gpu --no-first-run --window-size=1280,720 https://example.com &", + ); sleep_ms(3000); sh_ok("DISPLAY=:99 scrot -z /tmp/e2e_chrome.png"); let bytes: u64 = sh_ok("stat -c %s /tmp/e2e_chrome.png").parse().unwrap(); - assert!(bytes > 10_000, "screenshot too small ({bytes}b), chrome didn't render"); + assert!( + bytes > 10_000, + "screenshot too small ({bytes}b), chrome didn't render" + ); } -#[test] #[ignore] fn t18_chrome_headless_dom() { +#[test] +#[ignore] +fn t18_chrome_headless_dom() { ensure_container(); let html = String::from_utf8_lossy(&sh("timeout 15 google-chrome --headless --dump-dom --no-sandbox https://example.com 2>/dev/null").stdout).to_string(); assert!(html.contains("Example Domain")); } -#[test] #[ignore] fn t19_chrome_click_navigates() { +#[test] +#[ignore] +fn t19_chrome_click_navigates() { ensure_container(); - let _ = sh("pkill -f chrome"); sleep_ms(500); - sh_ok("DISPLAY=:99 google-chrome --no-sandbox --disable-gpu --no-first-run --window-size=1280,720 https://example.com &"); + let _ = sh("pkill -f chrome"); + sleep_ms(500); + sh_ok( + "DISPLAY=:99 google-chrome --no-sandbox --disable-gpu --no-first-run --window-size=1280,720 https://example.com &", + ); sleep_ms(3000); sh_ok("DISPLAY=:99 xdotool mousemove 266 353 click 1"); sleep_ms(3000); @@ -225,29 +331,43 @@ fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } // 7. PLAYWRIGHT // ═══════════════════════════════════════════════════════════ -#[test] #[ignore] fn t20_playwright_import() { +#[test] +#[ignore] +fn t20_playwright_import() { ensure_container(); let out = sh_ok("python3 -c 'from playwright.sync_api import sync_playwright; print(\"ok\")'"); assert_eq!(out, "ok"); } -#[test] #[ignore] fn t21_playwright_title() { +#[test] +#[ignore] +fn t21_playwright_title() { ensure_container(); - let title = sh_ok("python3 << 'PY'\nfrom playwright.sync_api import sync_playwright\nwith sync_playwright() as p:\n b=p.chromium.launch(headless=True)\n pg=b.new_page(); pg.goto('https://example.com')\n print(pg.title()); b.close()\nPY"); + let title = sh_ok( + "python3 << 'PY'\nfrom playwright.sync_api import sync_playwright\nwith sync_playwright() as p:\n b=p.chromium.launch(headless=True)\n pg=b.new_page(); pg.goto('https://example.com')\n print(pg.title()); b.close()\nPY", + ); assert_eq!(title, "Example Domain"); } -#[test] #[ignore] fn t22_playwright_selectors() { +#[test] +#[ignore] +fn t22_playwright_selectors() { ensure_container(); - let out = sh_ok("python3 << 'PY'\nfrom playwright.sync_api import sync_playwright\nwith sync_playwright() as p:\n b=p.chromium.launch(headless=True)\n pg=b.new_page(); pg.goto('https://example.com')\n h1=pg.query_selector('h1').inner_text()\n n=len(pg.query_selector_all('a'))\n print(f'{h1}|{n}'); b.close()\nPY"); + let out = sh_ok( + "python3 << 'PY'\nfrom playwright.sync_api import sync_playwright\nwith sync_playwright() as p:\n b=p.chromium.launch(headless=True)\n pg=b.new_page(); pg.goto('https://example.com')\n h1=pg.query_selector('h1').inner_text()\n n=len(pg.query_selector_all('a'))\n print(f'{h1}|{n}'); b.close()\nPY", + ); let parts: Vec<&str> = out.split('|').collect(); assert_eq!(parts[0], "Example Domain"); assert!(parts[1].parse::().unwrap() > 0); } -#[test] #[ignore] fn t23_playwright_screenshot() { +#[test] +#[ignore] +fn t23_playwright_screenshot() { ensure_container(); - sh_ok("python3 << 'PY'\nfrom playwright.sync_api import sync_playwright\nwith sync_playwright() as p:\n b=p.chromium.launch(headless=True)\n pg=b.new_page(); pg.goto('https://example.com')\n pg.screenshot(path='/tmp/e2e_pw.png'); b.close()\nPY"); + sh_ok( + "python3 << 'PY'\nfrom playwright.sync_api import sync_playwright\nwith sync_playwright() as p:\n b=p.chromium.launch(headless=True)\n pg=b.new_page(); pg.goto('https://example.com')\n pg.screenshot(path='/tmp/e2e_pw.png'); b.close()\nPY", + ); assert_eq!(sh_ok("od -A n -t x1 -N 4 /tmp/e2e_pw.png"), "89 50 4e 47"); } @@ -255,19 +375,32 @@ fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } // 8. SCRAPLING // ═══════════════════════════════════════════════════════════ -#[test] #[ignore] fn t24_scrapling_version() { +#[test] +#[ignore] +fn t24_scrapling_version() { ensure_container(); assert!(sh_ok("python3 -c 'import scrapling; print(scrapling.__version__)'").starts_with("0.")); } -#[test] #[ignore] fn t25_scrapling_h1() { +#[test] +#[ignore] +fn t25_scrapling_h1() { ensure_container(); - assert_eq!(sh_ok("python3 -c \"from scrapling import Fetcher; r=Fetcher().get('https://example.com'); print(r.css('h1')[0].text)\""), "Example Domain"); + assert_eq!( + sh_ok( + "python3 -c \"from scrapling import Fetcher; r=Fetcher().get('https://example.com'); print(r.css('h1')[0].text)\"" + ), + "Example Domain" + ); } -#[test] #[ignore] fn t26_scrapling_multi_selector() { +#[test] +#[ignore] +fn t26_scrapling_multi_selector() { ensure_container(); - let out = sh_ok("python3 << 'PY'\nfrom scrapling import Fetcher\nr=Fetcher().get('https://example.com')\nprint(f\"{r.css('h1')[0].text}|{len(r.css('p'))}|{len(r.css('a'))}\")\nPY"); + let out = sh_ok( + "python3 << 'PY'\nfrom scrapling import Fetcher\nr=Fetcher().get('https://example.com')\nprint(f\"{r.css('h1')[0].text}|{len(r.css('p'))}|{len(r.css('a'))}\")\nPY", + ); let p: Vec<&str> = out.split('|').collect(); assert_eq!(p[0], "Example Domain"); assert!(p[1].parse::().unwrap() > 0); @@ -278,12 +411,16 @@ fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } // 9. NODE // ═══════════════════════════════════════════════════════════ -#[test] #[ignore] fn t27_node_version() { +#[test] +#[ignore] +fn t27_node_version() { ensure_container(); assert!(sh_ok("node --version").starts_with('v')); } -#[test] #[ignore] fn t28_computer_use_mcp() { +#[test] +#[ignore] +fn t28_computer_use_mcp() { ensure_container(); let out = sh_ok("npm list -g --depth=0 2>/dev/null | grep computer-use-mcp || echo missing"); assert!(!out.contains("missing"), "computer-use-mcp not installed"); @@ -293,22 +430,30 @@ fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } // 10. SECURITY // ═══════════════════════════════════════════════════════════ -#[test] #[ignore] fn t29_sandbox_user() { +#[test] +#[ignore] +fn t29_sandbox_user() { ensure_container(); assert_eq!(sh_ok("whoami"), "sandbox"); } -#[test] #[ignore] fn t30_not_root() { +#[test] +#[ignore] +fn t30_not_root() { ensure_container(); assert_ne!(sh_ok("id -u"), "0"); } -#[test] #[ignore] fn t31_home_writable() { +#[test] +#[ignore] +fn t31_home_writable() { ensure_container(); sh_ok("touch ~/e2e_test && rm ~/e2e_test"); } -#[test] #[ignore] fn t32_system_dirs_readonly() { +#[test] +#[ignore] +fn t32_system_dirs_readonly() { ensure_container(); assert_ne!(sh_code("touch /usr/bin/e2e 2>/dev/null"), 0); assert_ne!(sh_code("touch /etc/e2e 2>/dev/null"), 0); @@ -318,37 +463,62 @@ fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } // 11. WORKFLOWS // ═══════════════════════════════════════════════════════════ -#[test] #[ignore] fn t33_workflow_scrape_then_visual() { +#[test] +#[ignore] +fn t33_workflow_scrape_then_visual() { ensure_container(); // Scrape - assert_eq!(sh_ok("python3 -c \"from scrapling import Fetcher; print(Fetcher().get('https://example.com').css('h1')[0].text)\""), "Example Domain"); + assert_eq!( + sh_ok( + "python3 -c \"from scrapling import Fetcher; print(Fetcher().get('https://example.com').css('h1')[0].text)\"" + ), + "Example Domain" + ); // Visual - let _ = sh("pkill -f chrome"); sleep_ms(500); - sh_ok("DISPLAY=:99 google-chrome --no-sandbox --disable-gpu --no-first-run --window-size=1280,720 https://example.com &"); + let _ = sh("pkill -f chrome"); + sleep_ms(500); + sh_ok( + "DISPLAY=:99 google-chrome --no-sandbox --disable-gpu --no-first-run --window-size=1280,720 https://example.com &", + ); sleep_ms(3000); sh_ok("DISPLAY=:99 scrot -z /tmp/e2e_wf1.png"); assert!(sh_ok("stat -c %s /tmp/e2e_wf1.png").parse::().unwrap() > 10_000); } -#[test] #[ignore] fn t34_workflow_type_url() { +#[test] +#[ignore] +fn t34_workflow_type_url() { ensure_container(); - let _ = sh("pkill -f chrome"); sleep_ms(500); - sh_ok("DISPLAY=:99 google-chrome --no-sandbox --disable-gpu --no-first-run --window-size=1280,720 about:blank &"); + let _ = sh("pkill -f chrome"); + sleep_ms(500); + sh_ok( + "DISPLAY=:99 google-chrome --no-sandbox --disable-gpu --no-first-run --window-size=1280,720 about:blank &", + ); sleep_ms(2000); - sh_ok("DISPLAY=:99 xdotool key ctrl+l"); sleep_ms(300); - sh_ok("DISPLAY=:99 xdotool type --delay 50 'https://example.com'"); sleep_ms(300); - sh_ok("DISPLAY=:99 xdotool key Return"); sleep_ms(3000); + sh_ok("DISPLAY=:99 xdotool key ctrl+l"); + sleep_ms(300); + sh_ok("DISPLAY=:99 xdotool type --delay 50 'https://example.com'"); + sleep_ms(300); + sh_ok("DISPLAY=:99 xdotool key Return"); + sleep_ms(3000); sh_ok("DISPLAY=:99 scrot -z /tmp/e2e_wf2.png"); assert!(sh_ok("stat -c %s /tmp/e2e_wf2.png").parse::().unwrap() > 10_000); } -#[test] #[ignore] fn t35_workflow_headed_and_headless_coexist() { +#[test] +#[ignore] +fn t35_workflow_headed_and_headless_coexist() { ensure_container(); - let _ = sh("pkill -f chrome"); sleep_ms(500); - sh_ok("DISPLAY=:99 google-chrome --no-sandbox --disable-gpu --no-first-run --window-size=1280,720 https://example.com &"); + let _ = sh("pkill -f chrome"); + sleep_ms(500); + sh_ok( + "DISPLAY=:99 google-chrome --no-sandbox --disable-gpu --no-first-run --window-size=1280,720 https://example.com &", + ); sleep_ms(1000); // Headless playwright while headed chrome is running - let h1 = sh_ok("python3 << 'PY'\nfrom playwright.sync_api import sync_playwright\nwith sync_playwright() as p:\n b=p.chromium.launch(headless=True)\n pg=b.new_page(); pg.goto('https://example.com')\n print(pg.query_selector('h1').inner_text()); b.close()\nPY"); + let h1 = sh_ok( + "python3 << 'PY'\nfrom playwright.sync_api import sync_playwright\nwith sync_playwright() as p:\n b=p.chromium.launch(headless=True)\n pg=b.new_page(); pg.goto('https://example.com')\n print(pg.query_selector('h1').inner_text()); b.close()\nPY", + ); assert_eq!(h1, "Example Domain"); // Headed chrome still alive assert!(!sh_ok("pgrep -f 'chrome.*no-sandbox' | head -1").is_empty()); @@ -358,9 +528,14 @@ fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } // 99. SHUTDOWN (must run last) // ═══════════════════════════════════════════════════════════ -#[test] #[ignore] fn t99_graceful_shutdown() { +#[test] +#[ignore] +fn t99_graceful_shutdown() { ensure_container(); assert!(docker(&["stop", "-t", "10", CONTAINER]).status.success()); - assert_eq!(docker_out(&["inspect", "-f", "{{.State.ExitCode}}", CONTAINER]), "0"); + assert_eq!( + docker_out(&["inspect", "-f", "{{.State.ExitCode}}", CONTAINER]), + "0" + ); let _ = docker(&["rm", "-f", CONTAINER]); } diff --git a/crates/reach-cli/tests/mcp_types.rs b/crates/reach-cli/tests/mcp_types.rs index 511d21e..ba1a626 100644 --- a/crates/reach-cli/tests/mcp_types.rs +++ b/crates/reach-cli/tests/mcp_types.rs @@ -9,10 +9,7 @@ use reach_cli::mcp::*; #[test] fn jsonrpc_response_success_has_no_error_field() { - let resp = JsonRpcResponse::success( - RequestId::Number(1), - serde_json::json!({"status": "ok"}), - ); + let resp = JsonRpcResponse::success(RequestId::Number(1), serde_json::json!({"status": "ok"})); let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"result\"")); assert!(!json.contains("\"error\"")); @@ -139,15 +136,13 @@ fn screenshot_tool_has_no_required_fields() { #[test] fn screenshot_params_default_format_is_png() { - let params: ScreenshotParams = - serde_json::from_str("{}").unwrap(); + let params: ScreenshotParams = serde_json::from_str("{}").unwrap(); assert!(matches!(params.format, ImageFormat::Png)); } #[test] fn click_params_default_button_is_left() { - let params: ClickParams = - serde_json::from_str(r#"{"x": 100, "y": 200}"#).unwrap(); + let params: ClickParams = serde_json::from_str(r#"{"x": 100, "y": 200}"#).unwrap(); assert!(matches!(params.button, MouseButton::Left)); assert_eq!(params.x, 100); assert_eq!(params.y, 200); @@ -155,8 +150,7 @@ fn click_params_default_button_is_left() { #[test] fn browse_params_default_headed_is_true() { - let params: BrowseParams = - serde_json::from_str(r#"{"url": "https://example.com"}"#).unwrap(); + let params: BrowseParams = serde_json::from_str(r#"{"url": "https://example.com"}"#).unwrap(); assert!(params.headed); } @@ -169,8 +163,7 @@ fn scrape_params_default_stealth_is_true() { #[test] fn exec_params_default_timeout_is_30() { - let params: ExecParams = - serde_json::from_str(r#"{"command": "ls"}"#).unwrap(); + let params: ExecParams = serde_json::from_str(r#"{"command": "ls"}"#).unwrap(); assert_eq!(params.timeout, 30); } @@ -241,10 +234,7 @@ fn sse_endpoint_message_carries_uri() { #[test] fn sse_message_wraps_jsonrpc_response() { - let resp = JsonRpcResponse::success( - RequestId::Number(1), - serde_json::json!({"tools": []}), - ); + let resp = JsonRpcResponse::success(RequestId::Number(1), serde_json::json!({"tools": []})); let msg = SseMessage::message(&resp); assert_eq!(msg.event, "message"); assert!(msg.data.contains("\"jsonrpc\"")); diff --git a/crates/reach-supervisor/src/main.rs b/crates/reach-supervisor/src/main.rs index ff4bb03..e27c09d 100644 --- a/crates/reach-supervisor/src/main.rs +++ b/crates/reach-supervisor/src/main.rs @@ -40,7 +40,9 @@ async fn main() -> anyhow::Result<()> { loop { tokio::time::sleep(Duration::from_secs(2)).await; let mut sup = supervision_shared.write().await; - if let Ok(restarted) = sup.check_and_restart().await && restarted > 0 { + if let Ok(restarted) = sup.check_and_restart().await + && restarted > 0 + { tracing::info!(restarted, "restarted crashed processes"); } } diff --git a/crates/reach-supervisor/src/processes.rs b/crates/reach-supervisor/src/processes.rs index f12d5e6..3e9260b 100644 --- a/crates/reach-supervisor/src/processes.rs +++ b/crates/reach-supervisor/src/processes.rs @@ -23,7 +23,10 @@ pub struct ProcessSpec { #[derive(Debug, Clone)] pub enum RestartPolicy { - Always { max_restarts: u32, backoff: Duration }, + Always { + max_restarts: u32, + backoff: Duration, + }, } #[derive(Debug, Clone)] @@ -203,10 +206,7 @@ impl Supervisor { backoff: Duration::from_secs(1), }, depends_on: vec![], - ready_check: ReadyCheck::FileExists(format!( - "/tmp/.X11-unix/X{}", - self.display - )), + ready_check: ReadyCheck::FileExists(format!("/tmp/.X11-unix/X{}", self.display)), }, ProcessSpec { name: "openbox", diff --git a/crates/reach-supervisor/src/signals.rs b/crates/reach-supervisor/src/signals.rs index b2eb2b5..6deeade 100644 --- a/crates/reach-supervisor/src/signals.rs +++ b/crates/reach-supervisor/src/signals.rs @@ -2,10 +2,8 @@ use anyhow::Result; /// Wait for SIGTERM or SIGINT, then return for graceful shutdown. pub async fn wait_for_shutdown() -> Result<()> { - let mut sigterm = - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; - let mut sigint = - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?; + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?; tokio::select! { _ = sigterm.recv() => tracing::info!("received SIGTERM"), diff --git a/deny.toml b/deny.toml index 5e0b85c..6972210 100644 --- a/deny.toml +++ b/deny.toml @@ -1,10 +1,28 @@ +# cargo-deny advisories configuration. +# `unmaintained` takes scope values {all, workspace, transitive, none} — +# "workspace" fails the build only when a direct workspace dependency is +# marked unmaintained in the advisory DB, without failing on transitive +# deps the workspace doesn't control. `vulnerability` was removed from the +# schema in cargo-deny 0.14+ (cargo-deny now always denies vulnerabilities +# in its default pipeline). [advisories] -vulnerability = "deny" -unmaintained = "warn" +unmaintained = "workspace" yanked = "warn" +ignore = [ + # protobuf 2.28.0 is pulled in transitively by prometheus 0.13 (our + # metrics exporter in reach-supervisor). RUSTSEC-2024-0437 is a stack + # overflow on parsing untrusted protobuf INPUT — reach-supervisor only + # EXPORTS metrics (serialises to text format over HTTP) and never + # parses protobuf input from the network, so the vector does not + # apply. Follow-up: upgrade to a metrics crate that uses protobuf 3.x + # or drop prometheus in favour of a text-only exporter. Tracked in + # the reach roadmap. + "RUSTSEC-2024-0437", +] +# cargo-deny 0.14+ removed the `unlicensed` key; the tool now treats +# unlicensed crates as deny-by-default inside the licenses pipeline. [licenses] -unlicensed = "deny" allow = [ "MIT", "Apache-2.0",