Skip to main content

wasmsh_testkit/
runner.rs

1//! TOML test runner engine.
2//!
3//! Reads declarative test case files, sets up VFS, executes scripts
4//! through `WorkerRuntime`, and compares results against expectations.
5
6use std::path::Path;
7
8use wasmsh_protocol::{HostCommand, WorkerEvent};
9use wasmsh_runtime::WorkerRuntime;
10
11use crate::toml_case::TomlTestFile;
12use crate::{features, oracle};
13
14/// Outcome of running a single test case.
15#[derive(Debug)]
16pub enum TestOutcome {
17    Passed,
18    Failed { reason: String },
19    Skipped { reason: String },
20}
21
22/// Run a TOML test case from a file path.
23pub fn run_toml_file(path: &Path) -> TestOutcome {
24    let content = match std::fs::read_to_string(path) {
25        Ok(c) => c,
26        Err(e) => {
27            return TestOutcome::Failed {
28                reason: format!("cannot read {}: {e}", path.display()),
29            };
30        }
31    };
32    let case: TomlTestFile = match toml::from_str(&content) {
33        Ok(c) => c,
34        Err(e) => {
35            return TestOutcome::Failed {
36                reason: format!("cannot parse {}: {e}", path.display()),
37            };
38        }
39    };
40    run_toml_case(&case)
41}
42
43/// Run a parsed TOML test case.
44pub fn run_toml_case(case: &TomlTestFile) -> TestOutcome {
45    run_toml_case_with_oracle(case, oracle::run_oracle)
46}
47
48fn run_toml_case_with_oracle<F>(case: &TomlTestFile, run_oracle: F) -> TestOutcome
49where
50    F: Fn(&str, &str) -> Option<oracle::OracleResult>,
51{
52    let missing = features::missing_features(&case.test.requires);
53    if !missing.is_empty() {
54        return TestOutcome::Skipped {
55            reason: format!("missing features: {}", missing.join(", ")),
56        };
57    }
58
59    let Some(script) = case.input.script.clone() else {
60        return TestOutcome::Failed {
61            reason: "no script provided".into(),
62        };
63    };
64
65    let mut rt = new_runtime();
66    seed_files(&mut rt, case);
67    seed_env(&mut rt, case);
68    let events = rt.handle_command(HostCommand::Run {
69        input: script.clone(),
70    });
71    let status = extract_exit_status(&events);
72    let stdout = collect_event_data(&events, |e| matches!(e, WorkerEvent::Stdout(_)));
73    let stderr = collect_event_data(&events, |e| matches!(e, WorkerEvent::Stderr(_)));
74
75    let mut failures = Vec::new();
76    compare_status(case, status, &mut failures);
77    compare_stream(
78        "stdout",
79        &stdout,
80        case.expect.stdout.as_ref(),
81        &mut failures,
82    );
83    compare_contains(
84        "stdout",
85        &stdout,
86        case.expect.stdout_contains.as_ref(),
87        &mut failures,
88    );
89    compare_stream(
90        "stderr",
91        &stderr,
92        case.expect.stderr.as_ref(),
93        &mut failures,
94    );
95    compare_contains(
96        "stderr",
97        &stderr,
98        case.expect.stderr_contains.as_ref(),
99        &mut failures,
100    );
101    compare_oracles(case, &script, status, &stdout, &run_oracle, &mut failures);
102    compare_files(case, &mut rt, &mut failures);
103    compare_env(case, &mut rt, &mut failures);
104
105    if failures.is_empty() {
106        TestOutcome::Passed
107    } else {
108        TestOutcome::Failed {
109            reason: failures.join("\n"),
110        }
111    }
112}
113
114fn compare_oracles<F>(
115    case: &TomlTestFile,
116    script: &str,
117    status: i32,
118    stdout: &str,
119    run_oracle: &F,
120    failures: &mut Vec<String>,
121) where
122    F: Fn(&str, &str) -> Option<oracle::OracleResult>,
123{
124    let Some(oracle_config) = case.oracle.as_ref() else {
125        return;
126    };
127    if !oracle_config.compare {
128        return;
129    }
130
131    let shells = if oracle_config.shells.is_empty() {
132        vec!["sh".to_string()]
133    } else {
134        oracle_config.shells.clone()
135    };
136
137    for shell in shells {
138        let Some(result) = run_oracle(script, shell.as_str()) else {
139            continue;
140        };
141        failures.extend(oracle::compare_oracle(
142            status,
143            stdout,
144            &result,
145            oracle_config.ignore_stderr,
146        ));
147    }
148}
149
150fn new_runtime() -> WorkerRuntime {
151    let mut rt = WorkerRuntime::new();
152    rt.handle_command(HostCommand::Init {
153        step_budget: 100_000,
154        allowed_hosts: vec![],
155    });
156    rt
157}
158
159fn seed_files(rt: &mut WorkerRuntime, case: &TomlTestFile) {
160    for (path, content) in &case.setup.files {
161        rt.handle_command(HostCommand::WriteFile {
162            path: path.clone(),
163            data: content.as_bytes().to_vec(),
164        });
165    }
166}
167
168fn seed_env(rt: &mut WorkerRuntime, case: &TomlTestFile) {
169    if case.setup.env.is_empty() {
170        return;
171    }
172    let env_script = case
173        .setup
174        .env
175        .iter()
176        .map(|(k, v)| format!("{k}={v}"))
177        .collect::<Vec<_>>()
178        .join("; ");
179    rt.handle_command(HostCommand::Run { input: env_script });
180}
181
182fn extract_exit_status(events: &[WorkerEvent]) -> i32 {
183    events
184        .iter()
185        .find_map(|event| match event {
186            WorkerEvent::Exit(status) => Some(*status),
187            _ => None,
188        })
189        .unwrap_or(-1)
190}
191
192fn compare_status(case: &TomlTestFile, status: i32, failures: &mut Vec<String>) {
193    if let Some(expected_status) = case.expect.status {
194        if status != expected_status {
195            failures.push(format!("status: expected {expected_status}, got {status}"));
196        }
197    }
198}
199
200fn compare_stream(
201    label: &str,
202    actual: &str,
203    expected: Option<&String>,
204    failures: &mut Vec<String>,
205) {
206    let Some(expected) = expected else {
207        return;
208    };
209    if actual != expected {
210        failures.push(format!(
211            "{label} mismatch:\n  expected: {expected:?}\n  got:      {actual:?}"
212        ));
213    }
214}
215
216fn compare_contains(
217    label: &str,
218    actual: &str,
219    expected: Option<&Vec<String>>,
220    failures: &mut Vec<String>,
221) {
222    let Some(expected) = expected else {
223        return;
224    };
225    for needle in expected {
226        if !actual.contains(needle.as_str()) {
227            failures.push(format!("{label} missing: {needle:?}"));
228        }
229    }
230}
231
232fn compare_files(case: &TomlTestFile, rt: &mut WorkerRuntime, failures: &mut Vec<String>) {
233    for (path, expected_content) in &case.expect.files {
234        let read_events = rt.handle_command(HostCommand::ReadFile { path: path.clone() });
235        let file_data = collect_event_data(&read_events, |e| matches!(e, WorkerEvent::Stdout(_)));
236        if file_data != *expected_content {
237            failures.push(format!(
238                "file {path} mismatch:\n  expected: {expected_content:?}\n  got:      {file_data:?}"
239            ));
240        }
241    }
242}
243
244fn compare_env(case: &TomlTestFile, rt: &mut WorkerRuntime, failures: &mut Vec<String>) {
245    for (name, expected_val) in &case.expect.env {
246        let check_events = rt.handle_command(HostCommand::Run {
247            input: format!("echo ${name}"),
248        });
249        let actual = collect_event_data(&check_events, |e| matches!(e, WorkerEvent::Stdout(_)));
250        let actual_trimmed = actual.trim_end_matches('\n');
251        if actual_trimmed != expected_val.as_str() {
252            failures.push(format!(
253                "env ${name} mismatch: expected {expected_val:?}, got {actual_trimmed:?}"
254            ));
255        }
256    }
257}
258
259fn collect_event_data<F>(events: &[WorkerEvent], pred: F) -> String
260where
261    F: Fn(&WorkerEvent) -> bool,
262{
263    let mut buf = Vec::new();
264    for e in events {
265        if pred(e) {
266            match e {
267                WorkerEvent::Stdout(data) | WorkerEvent::Stderr(data) => {
268                    buf.extend_from_slice(data);
269                }
270                _ => {}
271            }
272        }
273    }
274    String::from_utf8(buf).unwrap_or_default()
275}
276
277/// Discover all `.toml` test case files under a directory.
278pub fn discover_cases(dir: &Path) -> Vec<std::path::PathBuf> {
279    fn walk(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
280        if let Ok(entries) = std::fs::read_dir(dir) {
281            for entry in entries.flatten() {
282                let path = entry.path();
283                if path.is_dir() {
284                    walk(&path, out);
285                } else if path.extension().is_some_and(|e| e == "toml") {
286                    out.push(path);
287                }
288            }
289        }
290    }
291
292    let mut cases = Vec::new();
293    if !dir.exists() {
294        return cases;
295    }
296    walk(dir, &mut cases);
297    cases.sort();
298    cases
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn parse_case(input: &str) -> TomlTestFile {
306        toml::from_str(input).expect("valid toml test case")
307    }
308
309    #[test]
310    fn oracle_mismatch_fails_when_oracle_runner_reports_diff() {
311        let case = parse_case(
312            r#"
313[test]
314name = "oracle mismatch"
315
316[input]
317script = "echo hello"
318
319[expect]
320status = 0
321stdout = "hello\n"
322
323[oracle]
324compare = true
325shells = ["stub-sh"]
326"#,
327        );
328
329        let outcome = run_toml_case_with_oracle(&case, |_, shell| {
330            Some(oracle::OracleResult {
331                shell: shell.to_string(),
332                status: 7,
333                stdout: "goodbye\n".into(),
334                stderr: String::new(),
335            })
336        });
337
338        match outcome {
339            TestOutcome::Failed { reason } => {
340                assert!(reason.contains("[stub-sh] status"), "{reason}");
341                assert!(reason.contains("[stub-sh] stdout differs"), "{reason}");
342            }
343            other => panic!("expected oracle mismatch failure, got {other:?}"),
344        }
345    }
346
347    #[test]
348    fn oracle_disabled_case_ignores_oracle_runner() {
349        let case = parse_case(
350            r#"
351[test]
352name = "oracle disabled"
353
354[input]
355script = "echo hello"
356
357[expect]
358status = 0
359stdout = "hello\n"
360
361[oracle]
362compare = false
363shells = ["stub-sh"]
364"#,
365        );
366
367        let outcome = run_toml_case_with_oracle(&case, |_, _| {
368            Some(oracle::OracleResult {
369                shell: "stub-sh".into(),
370                status: 7,
371                stdout: "goodbye\n".into(),
372                stderr: String::new(),
373            })
374        });
375
376        assert!(matches!(outcome, TestOutcome::Passed));
377    }
378}