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::features;
12use crate::toml_case::TomlTestFile;
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    let missing = features::missing_features(&case.test.requires);
46    if !missing.is_empty() {
47        return TestOutcome::Skipped {
48            reason: format!("missing features: {}", missing.join(", ")),
49        };
50    }
51
52    let Some(script) = case.input.script.clone() else {
53        return TestOutcome::Failed {
54            reason: "no script provided".into(),
55        };
56    };
57
58    let mut rt = new_runtime();
59    seed_files(&mut rt, case);
60    seed_env(&mut rt, case);
61    let events = rt.handle_command(HostCommand::Run { input: script });
62    let status = extract_exit_status(&events);
63    let stdout = collect_event_data(&events, |e| matches!(e, WorkerEvent::Stdout(_)));
64    let stderr = collect_event_data(&events, |e| matches!(e, WorkerEvent::Stderr(_)));
65
66    let mut failures = Vec::new();
67    compare_status(case, status, &mut failures);
68    compare_stream(
69        "stdout",
70        &stdout,
71        case.expect.stdout.as_ref(),
72        &mut failures,
73    );
74    compare_contains(
75        "stdout",
76        &stdout,
77        case.expect.stdout_contains.as_ref(),
78        &mut failures,
79    );
80    compare_stream(
81        "stderr",
82        &stderr,
83        case.expect.stderr.as_ref(),
84        &mut failures,
85    );
86    compare_contains(
87        "stderr",
88        &stderr,
89        case.expect.stderr_contains.as_ref(),
90        &mut failures,
91    );
92    compare_files(case, &mut rt, &mut failures);
93    compare_env(case, &mut rt, &mut failures);
94
95    if failures.is_empty() {
96        TestOutcome::Passed
97    } else {
98        TestOutcome::Failed {
99            reason: failures.join("\n"),
100        }
101    }
102}
103
104fn new_runtime() -> WorkerRuntime {
105    let mut rt = WorkerRuntime::new();
106    rt.handle_command(HostCommand::Init {
107        step_budget: 100_000,
108        allowed_hosts: vec![],
109    });
110    rt
111}
112
113fn seed_files(rt: &mut WorkerRuntime, case: &TomlTestFile) {
114    for (path, content) in &case.setup.files {
115        rt.handle_command(HostCommand::WriteFile {
116            path: path.clone(),
117            data: content.as_bytes().to_vec(),
118        });
119    }
120}
121
122fn seed_env(rt: &mut WorkerRuntime, case: &TomlTestFile) {
123    if case.setup.env.is_empty() {
124        return;
125    }
126    let env_script = case
127        .setup
128        .env
129        .iter()
130        .map(|(k, v)| format!("{k}={v}"))
131        .collect::<Vec<_>>()
132        .join("; ");
133    rt.handle_command(HostCommand::Run { input: env_script });
134}
135
136fn extract_exit_status(events: &[WorkerEvent]) -> i32 {
137    events
138        .iter()
139        .find_map(|event| match event {
140            WorkerEvent::Exit(status) => Some(*status),
141            _ => None,
142        })
143        .unwrap_or(-1)
144}
145
146fn compare_status(case: &TomlTestFile, status: i32, failures: &mut Vec<String>) {
147    if let Some(expected_status) = case.expect.status {
148        if status != expected_status {
149            failures.push(format!("status: expected {expected_status}, got {status}"));
150        }
151    }
152}
153
154fn compare_stream(
155    label: &str,
156    actual: &str,
157    expected: Option<&String>,
158    failures: &mut Vec<String>,
159) {
160    let Some(expected) = expected else {
161        return;
162    };
163    if actual != expected {
164        failures.push(format!(
165            "{label} mismatch:\n  expected: {expected:?}\n  got:      {actual:?}"
166        ));
167    }
168}
169
170fn compare_contains(
171    label: &str,
172    actual: &str,
173    expected: Option<&Vec<String>>,
174    failures: &mut Vec<String>,
175) {
176    let Some(expected) = expected else {
177        return;
178    };
179    for needle in expected {
180        if !actual.contains(needle.as_str()) {
181            failures.push(format!("{label} missing: {needle:?}"));
182        }
183    }
184}
185
186fn compare_files(case: &TomlTestFile, rt: &mut WorkerRuntime, failures: &mut Vec<String>) {
187    for (path, expected_content) in &case.expect.files {
188        let read_events = rt.handle_command(HostCommand::ReadFile { path: path.clone() });
189        let file_data = collect_event_data(&read_events, |e| matches!(e, WorkerEvent::Stdout(_)));
190        if file_data != *expected_content {
191            failures.push(format!(
192                "file {path} mismatch:\n  expected: {expected_content:?}\n  got:      {file_data:?}"
193            ));
194        }
195    }
196}
197
198fn compare_env(case: &TomlTestFile, rt: &mut WorkerRuntime, failures: &mut Vec<String>) {
199    for (name, expected_val) in &case.expect.env {
200        let check_events = rt.handle_command(HostCommand::Run {
201            input: format!("echo ${name}"),
202        });
203        let actual = collect_event_data(&check_events, |e| matches!(e, WorkerEvent::Stdout(_)));
204        let actual_trimmed = actual.trim_end_matches('\n');
205        if actual_trimmed != expected_val.as_str() {
206            failures.push(format!(
207                "env ${name} mismatch: expected {expected_val:?}, got {actual_trimmed:?}"
208            ));
209        }
210    }
211}
212
213fn collect_event_data<F>(events: &[WorkerEvent], pred: F) -> String
214where
215    F: Fn(&WorkerEvent) -> bool,
216{
217    let mut buf = Vec::new();
218    for e in events {
219        if pred(e) {
220            match e {
221                WorkerEvent::Stdout(data) | WorkerEvent::Stderr(data) => {
222                    buf.extend_from_slice(data);
223                }
224                _ => {}
225            }
226        }
227    }
228    String::from_utf8(buf).unwrap_or_default()
229}
230
231/// Discover all `.toml` test case files under a directory.
232pub fn discover_cases(dir: &Path) -> Vec<std::path::PathBuf> {
233    fn walk(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
234        if let Ok(entries) = std::fs::read_dir(dir) {
235            for entry in entries.flatten() {
236                let path = entry.path();
237                if path.is_dir() {
238                    walk(&path, out);
239                } else if path.extension().is_some_and(|e| e == "toml") {
240                    out.push(path);
241                }
242            }
243        }
244    }
245
246    let mut cases = Vec::new();
247    if !dir.exists() {
248        return cases;
249    }
250    walk(dir, &mut cases);
251    cases.sort();
252    cases
253}