1use std::path::Path;
7
8use wasmsh_protocol::{HostCommand, WorkerEvent};
9use wasmsh_runtime::WorkerRuntime;
10
11use crate::features;
12use crate::toml_case::TomlTestFile;
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 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
231pub 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}