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}