Skip to main content

atman_runtime/tools/
test.rs

1use std::path::{Path, PathBuf};
2use std::time::{Duration, Instant};
3
4use crate::error::RuntimeError;
5use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
6use crate::value::Value;
7
8pub struct TestRun;
9
10impl Tool for TestRun {
11    fn name(&self) -> &str {
12        "test.run"
13    }
14
15    fn tier(&self) -> Tier {
16        Tier::Two
17    }
18
19    fn description(&self) -> Option<&str> {
20        Some(
21            "Run tests with auto-detected framework (cargo/npm/pytest/go). Returns exit code, stdout/stderr tail, duration, timed_out flag. Use scope to filter (e.g. scope: 'integration' for cargo).",
22        )
23    }
24
25    fn input_schema(&self) -> serde_json::Value {
26        serde_json::json!({
27            "type": "object",
28            "properties": {
29                "cwd": {"type": "string", "description": "Working directory to run tests in. Defaults to the current process directory."},
30                "framework": {"type": "string", "enum": ["cargo", "npm", "pytest", "go"], "description": "Optional framework override. Auto-detected from project files when omitted."},
31                "scope": {"type": "string", "description": "Optional framework-specific test filter or path, such as 'integration' for cargo."},
32                "timeout_ms": {"type": "integer", "default": 300000, "description": "Maximum runtime in milliseconds before returning timed_out=true."},
33                "tail_lines": {"type": "integer", "default": 80, "description": "Number of stdout/stderr lines to include from the end of each stream."}
34            }
35        })
36    }
37
38    fn invocation_provenance(
39        &self,
40        args: &ToolArgs,
41        ctx: &ToolCtx,
42    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
43        Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
44            .with_cwd(ctx, extract_optional_path(args, "cwd").as_deref())?
45            .with_risk(crate::trust::RiskKind::ProcessSpawn))
46    }
47
48    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
49        Box::pin(async move {
50            let explicit_cwd = extract_optional_path(&args, "cwd");
51            let cwd = ctx.resolve_cwd(explicit_cwd.as_deref())?;
52            crate::fs_access::authorize_write(ctx, &cwd, self.name(), false).await?;
53            let framework_override = extract_optional_string(&args, "framework");
54            let scope = extract_optional_string(&args, "scope");
55            let timeout_ms = extract_optional_int(&args, "timeout_ms").unwrap_or(300_000) as u64;
56            let tail_lines = extract_optional_int(&args, "tail_lines").unwrap_or(80) as usize;
57
58            let framework = match framework_override {
59                Some(name) => name,
60                None => detect_framework(&cwd)?,
61            };
62            let cmd = build_command(&framework, scope.as_deref())?;
63            let authorization = ctx.invocation_authorization_for("test.run")?;
64            let cmd_refs = cmd.iter().map(String::as_str).collect::<Vec<_>>();
65
66            let start = Instant::now();
67            let spawn_start = Instant::now();
68            let output_fut = async {
69                match authorization.execution_boundary() {
70                    crate::permission::ExecutionBoundary::Sandboxed => {
71                        let sandbox = ctx.sandbox.as_ref().ok_or_else(|| {
72                            RuntimeError::ToolFailed(
73                                "test.run: sandbox unavailable for controlled execution".into(),
74                            )
75                        })?;
76                        sandbox.spawn(&cmd_refs, &[], &cwd, authorization).await
77                    }
78                    crate::permission::ExecutionBoundary::Direct => {
79                        let mut child = tokio::process::Command::new(&cmd[0]);
80                        child.args(&cmd[1..]).current_dir(&cwd).kill_on_drop(true);
81                        child.output().await.map_err(|error| {
82                            RuntimeError::ToolFailed(format!(
83                                "test.run spawn `{}`: {error}",
84                                cmd.join(" ")
85                            ))
86                        })
87                    }
88                }
89            };
90            let output = match tokio::time::timeout(Duration::from_millis(timeout_ms), output_fut)
91                .await
92            {
93                Ok(Ok(out)) => out,
94                Ok(Err(error)) => return Err(error),
95                Err(_) => {
96                    return Ok(Value::Struct(vec![
97                        ("exit".into(), Value::Int(-1)),
98                        ("framework".into(), Value::Str(framework)),
99                        ("stdout_tail".into(), Value::Str(String::new())),
100                        (
101                            "stderr_tail".into(),
102                            Value::Str(format!("[atman] test.run timeout after {timeout_ms}ms")),
103                        ),
104                        (
105                            "duration_ms".into(),
106                            Value::Int(spawn_start.elapsed().as_millis() as i64),
107                        ),
108                        ("timed_out".into(), Value::Bool(true)),
109                        ("cmd".into(), Value::Str(cmd.join(" "))),
110                    ]));
111                }
112            };
113            let duration_ms = start.elapsed().as_millis() as i64;
114            let exit = output.status.code().unwrap_or(-1) as i64;
115            let stdout = String::from_utf8_lossy(&output.stdout);
116            let stderr = String::from_utf8_lossy(&output.stderr);
117            Ok(Value::Struct(vec![
118                ("exit".into(), Value::Int(exit)),
119                ("framework".into(), Value::Str(framework)),
120                (
121                    "stdout_tail".into(),
122                    Value::Str(tail_of(&stdout, tail_lines)),
123                ),
124                (
125                    "stderr_tail".into(),
126                    Value::Str(tail_of(&stderr, tail_lines)),
127                ),
128                ("duration_ms".into(), Value::Int(duration_ms)),
129                ("timed_out".into(), Value::Bool(false)),
130                ("cmd".into(), Value::Str(cmd.join(" "))),
131            ]))
132        })
133    }
134}
135
136fn detect_framework(cwd: &Path) -> Result<String, RuntimeError> {
137    if cwd.join("Cargo.toml").exists() {
138        return Ok("cargo".into());
139    }
140    if cwd.join("package.json").exists() {
141        return Ok("npm".into());
142    }
143    if cwd.join("pyproject.toml").exists() || cwd.join("pytest.ini").exists() {
144        return Ok("pytest".into());
145    }
146    if cwd.join("go.mod").exists() {
147        return Ok("go".into());
148    }
149    Err(RuntimeError::ToolFailed(format!(
150        "test.run: no known test framework detected in {} (looked for Cargo.toml / package.json / pyproject.toml / go.mod). Pass `framework:` to override.",
151        cwd.display()
152    )))
153}
154
155fn build_command(framework: &str, scope: Option<&str>) -> Result<Vec<String>, RuntimeError> {
156    Ok(match framework {
157        "cargo" => match scope {
158            Some(s) => vec!["cargo".into(), "test".into(), "--".into(), s.into()],
159            None => vec!["cargo".into(), "test".into()],
160        },
161        "npm" => match scope {
162            Some(s) => vec!["npm".into(), "test".into(), "--".into(), s.into()],
163            None => vec!["npm".into(), "test".into()],
164        },
165        "pytest" => match scope {
166            Some(s) => vec!["pytest".into(), s.into()],
167            None => vec!["pytest".into()],
168        },
169        "go" => match scope {
170            Some(s) => vec!["go".into(), "test".into(), s.into()],
171            None => vec!["go".into(), "test".into(), "./...".into()],
172        },
173        other => {
174            return Err(RuntimeError::ToolFailed(format!(
175                "test.run: unknown framework `{other}` (want cargo | npm | pytest | go)"
176            )));
177        }
178    })
179}
180
181fn tail_of(s: &str, n: usize) -> String {
182    let lines: Vec<&str> = s.lines().collect();
183    let start = lines.len().saturating_sub(n);
184    lines[start..].join("\n")
185}
186
187fn extract_optional_string(args: &ToolArgs, name: &str) -> Option<String> {
188    match args.named(name)? {
189        Value::Str(s) => Some(s.clone()),
190        _ => None,
191    }
192}
193
194fn extract_optional_int(args: &ToolArgs, name: &str) -> Option<i64> {
195    match args.named(name)? {
196        Value::Int(n) => Some(*n),
197        _ => None,
198    }
199}
200
201fn extract_optional_path(args: &ToolArgs, name: &str) -> Option<PathBuf> {
202    match args.named(name)? {
203        Value::Path(p) => Some(p.clone()),
204        Value::Str(s) => Some(PathBuf::from(s)),
205        _ => None,
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    struct RecordingTestSandbox {
214        calls: std::sync::atomic::AtomicUsize,
215    }
216
217    impl crate::sandbox::Sandbox for RecordingTestSandbox {
218        fn spawn<'a>(
219            &'a self,
220            _cmd: &'a [&'a str],
221            _env: &'a [(String, String)],
222            _cwd: &'a Path,
223            _authorization: &'a crate::permission::InvocationAuthorization,
224        ) -> crate::tool::BoxFut<'a, Result<std::process::Output, RuntimeError>> {
225            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
226            Box::pin(async {
227                std::process::Command::new("true")
228                    .output()
229                    .map_err(|error| RuntimeError::ToolFailed(error.to_string()))
230            })
231        }
232
233        fn prepare_background(
234            &self,
235            _cmd: &[&str],
236            _env: &[(String, String)],
237            _cwd: &Path,
238            _authorization: &crate::permission::InvocationAuthorization,
239        ) -> Result<Box<dyn crate::sandbox::BackgroundLauncher>, crate::sandbox::SandboxLaunchError>
240        {
241            Err(crate::sandbox::SandboxLaunchError::Runtime(
242                RuntimeError::ToolFailed("unsupported".into()),
243            ))
244        }
245
246        fn spawn_pty<'a>(
247            &'a self,
248            _cmd: &'a [&'a str],
249            _env: &'a [(String, String)],
250            _cwd: &'a Path,
251            _pty_size: portable_pty::PtySize,
252            _authorization: &'a crate::permission::InvocationAuthorization,
253        ) -> crate::tool::BoxFut<
254            'a,
255            Result<crate::sandbox::PtySpawnResult, crate::sandbox::SandboxLaunchError>,
256        > {
257            Box::pin(async {
258                Err(crate::sandbox::SandboxLaunchError::Runtime(
259                    RuntimeError::ToolFailed("unsupported".into()),
260                ))
261            })
262        }
263
264        fn is_available(&self) -> bool {
265            true
266        }
267
268        fn kind(&self) -> &'static str {
269            "test"
270        }
271    }
272
273    fn authorize(
274        ctx: ToolCtx,
275        execution_boundary: crate::permission::ExecutionBoundary,
276    ) -> ToolCtx {
277        ctx.authorized_for(crate::permission::InvocationAuthorization::new(
278            crate::permission::PermissionRequestId::now(),
279            "test-call",
280            "test.run",
281            crate::permission::ResourceProvenance::none(),
282            execution_boundary,
283        ))
284    }
285
286    #[test]
287    fn provenance_uses_cwd_and_reports_process_spawn() {
288        let dir = tempfile::tempdir().unwrap();
289        let ctx = ToolCtx::default();
290        let args = ToolArgs {
291            named: vec![
292                ("cwd".into(), Value::Str(dir.path().display().to_string())),
293                ("scope".into(), Value::Str("integration".into())),
294            ],
295            ..ToolArgs::default()
296        };
297        let provenance = TestRun.invocation_provenance(&args, &ctx).unwrap();
298        let cwd = provenance.cwd.expect("cwd recorded");
299        assert_eq!(
300            std::fs::canonicalize(&cwd).unwrap(),
301            std::fs::canonicalize(dir.path()).unwrap()
302        );
303        assert_eq!(provenance.path, None);
304        assert!(
305            provenance
306                .risks
307                .contains(&crate::trust::RiskKind::ProcessSpawn)
308        );
309    }
310
311    #[test]
312    fn detect_cargo_from_cargo_toml() {
313        let dir = tempfile::tempdir().unwrap();
314        std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
315        assert_eq!(detect_framework(dir.path()).unwrap(), "cargo");
316    }
317
318    #[test]
319    fn detect_npm_from_package_json() {
320        let dir = tempfile::tempdir().unwrap();
321        std::fs::write(dir.path().join("package.json"), "{}").unwrap();
322        assert_eq!(detect_framework(dir.path()).unwrap(), "npm");
323    }
324
325    #[test]
326    fn detect_pytest_from_pyproject_toml() {
327        let dir = tempfile::tempdir().unwrap();
328        std::fs::write(dir.path().join("pyproject.toml"), "").unwrap();
329        assert_eq!(detect_framework(dir.path()).unwrap(), "pytest");
330    }
331
332    #[test]
333    fn detect_pytest_from_pytest_ini() {
334        let dir = tempfile::tempdir().unwrap();
335        std::fs::write(dir.path().join("pytest.ini"), "").unwrap();
336        assert_eq!(detect_framework(dir.path()).unwrap(), "pytest");
337    }
338
339    #[test]
340    fn detect_go_from_go_mod() {
341        let dir = tempfile::tempdir().unwrap();
342        std::fs::write(dir.path().join("go.mod"), "module x").unwrap();
343        assert_eq!(detect_framework(dir.path()).unwrap(), "go");
344    }
345
346    #[test]
347    fn detect_cargo_wins_over_go_when_both_present() {
348        let dir = tempfile::tempdir().unwrap();
349        std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
350        std::fs::write(dir.path().join("go.mod"), "module x").unwrap();
351        assert_eq!(detect_framework(dir.path()).unwrap(), "cargo");
352    }
353
354    #[test]
355    fn detect_errors_when_no_markers() {
356        let dir = tempfile::tempdir().unwrap();
357        let err = detect_framework(dir.path()).unwrap_err();
358        assert!(format!("{err}").contains("no known test framework"));
359    }
360
361    #[test]
362    fn build_cargo_command_with_scope_appends_after_dash_dash() {
363        let cmd = build_command("cargo", Some("integration")).unwrap();
364        assert_eq!(cmd, vec!["cargo", "test", "--", "integration"]);
365    }
366
367    #[test]
368    fn build_go_command_defaults_to_all_packages() {
369        let cmd = build_command("go", None).unwrap();
370        assert_eq!(cmd, vec!["go", "test", "./..."]);
371    }
372
373    #[test]
374    fn build_unknown_framework_errors() {
375        let err = build_command("mocha", None).unwrap_err();
376        assert!(format!("{err}").contains("unknown framework"));
377    }
378
379    #[tokio::test]
380    async fn test_run_returns_all_structured_fields() {
381        let dir = tempfile::tempdir().unwrap();
382        std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
383        let tool = TestRun;
384        let ctx = authorize(ToolCtx::new(), crate::permission::ExecutionBoundary::Direct);
385        let args = ToolArgs {
386            positional: vec![],
387            named: vec![
388                ("framework".into(), Value::Str("cargo".into())),
389                ("cwd".into(), Value::Path(dir.path().to_path_buf())),
390                ("timeout_ms".into(), Value::Int(30_000)),
391            ],
392        };
393        let v = tool.call(args, &ctx).await.unwrap();
394        let Value::Struct(fields) = v else {
395            panic!("expected struct");
396        };
397        let f = |k: &str| fields.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
398        assert!(matches!(f("framework"), Some(Value::Str(s)) if s == "cargo"));
399        assert!(matches!(f("exit"), Some(Value::Int(_))));
400        assert!(matches!(f("duration_ms"), Some(Value::Int(_))));
401        assert!(matches!(f("timed_out"), Some(Value::Bool(_))));
402        assert!(matches!(f("cmd"), Some(Value::Str(s)) if s.starts_with("cargo test")));
403    }
404
405    fn managed_ctx(workspace: &Path) -> ToolCtx {
406        authorize(
407            ToolCtx::new()
408                .with_fs_access(crate::fs_access::FsAccessPolicy::workspace_write(
409                    workspace.to_path_buf(),
410                ))
411                .with_workspace(crate::git_workspace::WorkspaceBinding {
412                    workspace_id: "test".into(),
413                    repository_root: workspace.to_path_buf(),
414                    path: workspace.to_path_buf(),
415                    branch: None,
416                }),
417            crate::permission::ExecutionBoundary::Direct,
418        )
419    }
420
421    fn run_args(cwd: &Path, framework: &str) -> ToolArgs {
422        ToolArgs {
423            positional: vec![],
424            named: vec![
425                ("framework".into(), Value::Str(framework.into())),
426                ("cwd".into(), Value::Path(cwd.to_path_buf())),
427                ("timeout_ms".into(), Value::Int(30_000)),
428            ],
429        }
430    }
431
432    #[tokio::test]
433    async fn managed_test_run_rejects_external_cwd_before_command_construction() {
434        let workspace = tempfile::tempdir().unwrap();
435        let external = Path::new(env!("CARGO_MANIFEST_DIR"));
436        let error = TestRun
437            .call(
438                run_args(external, "must-not-be-parsed"),
439                &managed_ctx(workspace.path()),
440            )
441            .await
442            .unwrap_err();
443        let message = error.to_string();
444        assert!(message.contains("outside workspace"), "{message}");
445        assert!(!message.contains("unknown framework"), "{message}");
446    }
447
448    #[tokio::test]
449    async fn managed_test_run_allows_temp_cwd() {
450        let workspace = tempfile::tempdir().unwrap();
451        let external_temp = tempfile::tempdir().unwrap();
452        std::fs::write(
453            external_temp.path().join("Cargo.toml"),
454            "[package]\nname='r4-temp'\nversion='0.0.0'\n",
455        )
456        .unwrap();
457        std::fs::create_dir(external_temp.path().join("src")).unwrap();
458        std::fs::write(external_temp.path().join("src/lib.rs"), "").unwrap();
459        let result = TestRun
460            .call(
461                run_args(external_temp.path(), "cargo"),
462                &managed_ctx(workspace.path()),
463            )
464            .await
465            .unwrap();
466        assert!(matches!(result.field("exit"), Some(Value::Int(0))));
467    }
468
469    #[tokio::test]
470    async fn enforced_authorization_uses_sandbox_runner() {
471        let workspace = tempfile::tempdir().unwrap();
472        let sandbox = std::sync::Arc::new(RecordingTestSandbox {
473            calls: std::sync::atomic::AtomicUsize::new(0),
474        });
475        let ctx = authorize(
476            managed_ctx(workspace.path()).with_sandbox(sandbox.clone()),
477            crate::permission::ExecutionBoundary::Sandboxed,
478        );
479
480        let result = TestRun
481            .call(run_args(workspace.path(), "cargo"), &ctx)
482            .await
483            .unwrap();
484
485        assert!(matches!(result.field("exit"), Some(Value::Int(0))));
486        assert_eq!(sandbox.calls.load(std::sync::atomic::Ordering::SeqCst), 1);
487    }
488
489    #[tokio::test]
490    async fn managed_test_run_allows_external_cwd_under_full_access() {
491        let fixture_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("target");
492        std::fs::create_dir_all(&fixture_root).unwrap();
493        let fixture = tempfile::Builder::new()
494            .prefix("r4-test-run-")
495            .tempdir_in(fixture_root)
496            .unwrap();
497        std::fs::create_dir_all(fixture.path().join("src")).unwrap();
498        std::fs::write(
499            fixture.path().join("Cargo.toml"),
500            "[package]\nname='r4-full'\nversion='0.0.0'\n\n[workspace]\n",
501        )
502        .unwrap();
503        std::fs::write(fixture.path().join("src/lib.rs"), "").unwrap();
504        let workspace = tempfile::tempdir().unwrap();
505        let ctx = managed_ctx(workspace.path())
506            .with_fs_access(crate::fs_access::FsAccessPolicy::danger_full_access());
507        let result = TestRun
508            .call(run_args(fixture.path(), "cargo"), &ctx)
509            .await
510            .unwrap();
511        assert!(matches!(result.field("exit"), Some(Value::Int(0))));
512    }
513
514    #[test]
515    fn tail_of_returns_last_n_lines() {
516        let s = "a\nb\nc\nd\ne";
517        assert_eq!(tail_of(s, 3), "c\nd\ne");
518        assert_eq!(tail_of(s, 10), s);
519        assert_eq!(tail_of("", 3), "");
520    }
521}