Skip to content

Commit 9706ff4

Browse files
authored
Merge pull request #28 from 100monkeys-ai/feat-test-runner-cli-16608379834186111812
feat: implement test runner in forge-cli with discovery, execution, and watch mode
2 parents 590c330 + 67e2524 commit 9706ff4

3 files changed

Lines changed: 147 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ anyhow = { workspace = true }
2626
tracing = { workspace = true }
2727
tracing-subscriber = { workspace = true }
2828
camino = { workspace = true }
29+
notify.workspace = true

cli/src/commands/test.rs

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
33
use anyhow::Result;
44
use clap::Args;
5+
use forge_runtime::isolate::v8_runtime::ForgeRuntime;
6+
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
7+
use std::path::{Path, PathBuf};
8+
use std::sync::Arc;
9+
use tokio::sync::Notify;
10+
use tokio::time::{sleep, Duration};
511

612
#[derive(Debug, Args)]
713
pub struct TestArgs {
@@ -12,7 +18,144 @@ pub struct TestArgs {
1218
pub filter: Option<String>,
1319
}
1420

15-
pub async fn run(_args: TestArgs) -> Result<()> {
16-
// TODO: Implement test runner
21+
fn find_tests<'a>(
22+
dir: &'a Path,
23+
filter: Option<&'a str>,
24+
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathBuf>>> + Send + 'a>> {
25+
Box::pin(async move {
26+
let mut tests = Vec::new();
27+
let mut entries = tokio::fs::read_dir(dir).await?;
28+
29+
while let Ok(Some(entry)) = entries.next_entry().await {
30+
let path = entry.path();
31+
if path.is_dir() {
32+
let name = path.file_name().unwrap_or_default().to_string_lossy();
33+
if name != "node_modules" && name != "dist" && name != "target" {
34+
tests.extend(find_tests(&path, filter).await?);
35+
}
36+
} else if let Some(ext) = path.extension() {
37+
if ext == "ts" || ext == "fx" {
38+
let name = path.file_name().unwrap_or_default().to_string_lossy();
39+
if name.ends_with(".test.ts") || name.ends_with(".test.fx") {
40+
if let Some(f) = filter {
41+
if !name.contains(f) && !path.to_string_lossy().contains(f) {
42+
continue;
43+
}
44+
}
45+
tests.push(path);
46+
}
47+
}
48+
}
49+
}
50+
Ok(tests)
51+
})
52+
}
53+
54+
async fn run_tests(filter: Option<&str>) -> Result<()> {
55+
crate::output::info("Discovering tests...");
56+
let mut tests = find_tests(Path::new("."), filter).await?;
57+
tests.sort();
58+
59+
if tests.is_empty() {
60+
crate::output::warn("No tests found matching the criteria.");
61+
return Ok(());
62+
}
63+
64+
crate::output::info(&format!("Found {} test file(s)", tests.len()));
65+
let mut passed = 0;
66+
let mut failed = 0;
67+
68+
let mut runtime = ForgeRuntime::new()?;
69+
70+
for test in &tests {
71+
crate::output::info(&format!("Running {}", test.display()));
72+
73+
let content = tokio::fs::read(test).await?;
74+
let execution_result = runtime.execute_module(&content).await;
75+
76+
match execution_result {
77+
Ok(_) => {
78+
crate::output::success(&format!("PASS {}", test.display()));
79+
passed += 1;
80+
}
81+
Err(e) => {
82+
crate::output::error(&format!("FAIL {}: {}", test.display(), e));
83+
failed += 1;
84+
}
85+
}
86+
}
87+
88+
if failed == 0 {
89+
crate::output::success(&format!("\nTest Summary: {} passed, 0 failed", passed));
90+
} else {
91+
crate::output::error(&format!(
92+
"\nTest Summary: {} passed, {} failed",
93+
passed, failed
94+
));
95+
anyhow::bail!("{} test(s) failed", failed);
96+
}
97+
1798
Ok(())
1899
}
100+
101+
fn is_relevant_event(event: &notify::Event) -> bool {
102+
const EXCLUDED: &[&str] = &["node_modules", "dist", "target"];
103+
if event.kind.is_modify() || event.kind.is_create() || event.kind.is_remove() {
104+
for path in &event.paths {
105+
if path.components().any(|c| {
106+
let s = c.as_os_str().to_string_lossy();
107+
EXCLUDED.contains(&s.as_ref())
108+
}) {
109+
continue;
110+
}
111+
if let Some(ext) = path.extension() {
112+
if ext == "ts" || ext == "fx" {
113+
return true;
114+
}
115+
}
116+
}
117+
}
118+
false
119+
}
120+
121+
pub async fn run(args: TestArgs) -> Result<()> {
122+
let filter = args.filter.as_deref();
123+
run_tests(filter).await?;
124+
125+
if args.watch {
126+
watch_loop(filter).await?;
127+
}
128+
129+
Ok(())
130+
}
131+
132+
async fn watch_loop(filter: Option<&str>) -> Result<()> {
133+
crate::output::info("Watching for file changes...");
134+
let notify = Arc::new(Notify::new());
135+
let notify_clone = Arc::clone(&notify);
136+
137+
let mut watcher = RecommendedWatcher::new(
138+
move |res: Result<notify::Event, notify::Error>| match res {
139+
Ok(event) => {
140+
if is_relevant_event(&event) {
141+
notify_clone.notify_one();
142+
}
143+
}
144+
Err(err) => {
145+
eprintln!("watch error: {err}");
146+
}
147+
},
148+
Config::default(),
149+
)?;
150+
151+
watcher.watch(Path::new("."), RecursiveMode::Recursive)?;
152+
153+
loop {
154+
notify.notified().await;
155+
sleep(Duration::from_millis(100)).await;
156+
crate::output::info("\nFile change detected. Re-running tests...");
157+
if let Err(err) = run_tests(filter).await {
158+
eprintln!("Error re-running tests: {err}");
159+
}
160+
}
161+
}

0 commit comments

Comments
 (0)