1use 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#[derive(Debug)]
16pub enum TestOutcome {
17 Passed,
18 Failed { reason: String },
19 Skipped { reason: String },
20}
21
22pub 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
43pub 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
277pub 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}