Skip to main content

mumutest/runner/
run_single.rs

1// src/runner/run_single.rs
2use mumu::parser::interpreter::Interpreter;
3use mumu::parser::types::{FunctionValue, Value};
4use serde::Deserialize;
5use serde_json::from_str;
6use std::fmt::Write as FmtWrite;
7use std::process::Command;
8
9use super::helper::{CloneFunction, HRULE, HRULE_PLAIN};
10
11#[derive(Deserialize)]
12pub struct JsonTestEntry {
13    pub name: String,
14    pub passed: bool,
15    pub time_us: i64,
16    pub output: String,
17}
18
19#[derive(Deserialize)]
20pub struct JsonFileReport {
21    pub suite: String,
22    pub tests: Vec<JsonTestEntry>,
23}
24
25#[derive(Clone)]
26pub struct TestEntry {
27    pub name: String,
28    pub passed: bool,
29    pub time_us: i64,
30    pub output: String,
31}
32
33#[derive(Clone)]
34pub struct FileReport {
35    pub suite: String,
36    pub tests: Vec<TestEntry>,
37}
38
39/// Local helper to call a callback with arguments.
40/// Supports both **named** and **inline** functions (via apply).
41fn call_named_or_inline_callback(interp: &mut Interpreter, cb: &Value, args: Vec<Value>) {
42    if let Some(fb) = cb.clone_function() {
43        match *fb {
44            FunctionValue::Named(ref name) => {
45                if let Some(df) = interp.get_dynamic_function(name) {
46                    let _ = (df.lock().unwrap())(interp, args);
47                } else {
48                    let _ = mumu::parser::interpreter::apply::apply_function_value(
49                        interp,
50                        fb,
51                        args,
52                    );
53                }
54            }
55            _ => {
56                let _ = mumu::parser::interpreter::apply::apply_function_value(interp, fb, args);
57            }
58        }
59    }
60}
61
62/// Execute one test file with the system‐wide `lava` binary and collect:
63/// 1. A boolean indicating whether *all* of its tests passed.
64/// 2. A pretty–printed console report (colourised when requested).
65/// 3. A flattened `FileReport` that contains **all** individual test results from
66///    *every* `describe` block inside the file (used by `test:all` when
67///    `errors_only=true` so that we never lose information about failing
68///    assertions).
69pub fn run_single_file_with_report(
70    interp: &mut Interpreter,
71    fname: &str,
72    cb: &Value,
73    colorize: bool,
74) -> Result<(bool, String, FileReport), String> {
75    //---------------------------------------------------------------------
76    // 1. Spawn `lava <file>` and capture its stdout/stderr
77    //---------------------------------------------------------------------
78    let output = Command::new("lava").arg(fname).output().map_err(|e| e.to_string())?;
79
80    let stdout_str = String::from_utf8_lossy(&output.stdout);
81    let stderr_str = String::from_utf8_lossy(&output.stderr);
82
83    //---------------------------------------------------------------------
84    // 2. Parse every JSON line that the file emitted
85    //---------------------------------------------------------------------
86    let mut per_suite_reports: Vec<FileReport> = Vec::new();
87
88    if output.status.success() {
89        for line in stdout_str.lines().filter(|l| !l.trim().is_empty()) {
90            if let Ok(js) = from_str::<JsonFileReport>(line) {
91                // Map JSON -> internal, but **treat empty test lists as a failure**
92                let mut mapped_tests: Vec<TestEntry> = js
93                    .tests
94                    .into_iter()
95                    .map(|t| TestEntry {
96                        name: t.name,
97                        passed: t.passed,
98                        time_us: t.time_us,
99                        output: t.output,
100                    })
101                    .collect();
102
103                if mapped_tests.is_empty() {
104                    mapped_tests.push(TestEntry {
105                        name: format!("{}: no tests discovered", js.suite),
106                        passed: false,
107                        time_us: 0,
108                        output: "Suite produced no tests (did the describe body run?)".into(),
109                    });
110                }
111
112                per_suite_reports.push(FileReport {
113                    suite: js.suite,
114                    tests: mapped_tests,
115                });
116            }
117        }
118
119        // If the file produced no valid JSON we still want to mark it as a
120        // failure so users notice immediately.
121        if per_suite_reports.is_empty() {
122            per_suite_reports.push(FileReport {
123                suite: fname.to_string(),
124                tests: vec![TestEntry {
125                    name: fname.to_string(),
126                    passed: false,
127                    time_us: 0,
128                    output: "No valid JSON output".into(),
129                }],
130            });
131        }
132    } else {
133        // Lava exited with a non-zero status – treat this entire file as a single
134        // failing “test”.
135        per_suite_reports.push(FileReport {
136            suite: fname.to_string(),
137            tests: vec![TestEntry {
138                name: fname.to_string(),
139                passed: false,
140                time_us: 0,
141                output: stderr_str.into(),
142            }],
143        });
144    }
145
146    //---------------------------------------------------------------------
147    // 3. Determine overall pass/fail for the *file*
148    //---------------------------------------------------------------------
149    let file_pass = per_suite_reports.iter().all(|r| r.tests.iter().all(|t| t.passed));
150
151    //---------------------------------------------------------------------
152    // 4. Build *flattened* report (all tests from all suites)
153    //---------------------------------------------------------------------
154    let mut flattened_tests = Vec::new();
155    for r in &per_suite_reports {
156        flattened_tests.extend(r.tests.clone());
157    }
158    let flattened_report = FileReport {
159        suite: fname.to_string(),
160        tests: flattened_tests,
161    };
162
163    //---------------------------------------------------------------------
164    // 5. Pretty console output
165    //---------------------------------------------------------------------
166    let mut out = String::new();
167
168    if colorize {
169        writeln!(out, "{HRULE}").unwrap();
170        if file_pass {
171            writeln!(out, "\x1b[1;32m{}\x1b[0m", fname).unwrap();
172        } else {
173            writeln!(out, "\x1b[1;31m{}\x1b[0m", fname).unwrap();
174        }
175    } else {
176        writeln!(out, "{HRULE_PLAIN}").unwrap();
177        writeln!(out, "{}", fname).unwrap();
178    }
179    writeln!(out).unwrap();
180
181    let mut first_suite = true;
182    for suite in &per_suite_reports {
183        if !first_suite {
184            writeln!(out).unwrap();
185        }
186        first_suite = false;
187
188        let suite_pass = suite.tests.iter().all(|t| t.passed);
189
190        if colorize {
191            if suite_pass {
192                writeln!(out, "\x1b[1m{}\x1b[0m", suite.suite).unwrap();
193            } else {
194                writeln!(out, "\x1b[1;31m{}\x1b[0m", suite.suite).unwrap();
195            }
196        } else {
197            writeln!(out, "{}", suite.suite).unwrap();
198        }
199
200        for t in &suite.tests {
201            if t.passed {
202                if colorize {
203                    writeln!(out, "\x1b[32m✔ {}\x1b[0m ({} µs)", t.name, t.time_us).unwrap();
204                } else {
205                    writeln!(out, "✔ {} ({} µs)", t.name, t.time_us).unwrap();
206                }
207            } else {
208                // ---------- failing test ----------
209                if colorize {
210                    writeln!(out, "\x1b[31m✖ {}\x1b[0m ({} µs)", t.name, t.time_us).unwrap();
211                } else {
212                    writeln!(out, "✖ {} ({} µs)", t.name, t.time_us).unwrap();
213                }
214                writeln!(out).unwrap();
215                for line in t.output.lines() {
216                    writeln!(out, "  {}", line).unwrap();
217                }
218                writeln!(out).unwrap();
219            }
220        }
221
222        // Fire user callback with the per-suite result (best-effort)
223        call_named_or_inline_callback(interp, cb, vec![Value::Bool(suite_pass)]);
224    }
225
226    if colorize {
227        if !file_pass {
228            writeln!(out).unwrap();
229            writeln!(out, "\x1b[1;31mFAILED\x1b[0m").unwrap();
230        }
231        writeln!(out).unwrap();
232    } else {
233        if !file_pass {
234            writeln!(out).unwrap();
235            writeln!(out, "FAILED").unwrap();
236        }
237        writeln!(out).unwrap();
238    }
239
240    Ok((file_pass, out, flattened_report))
241}
242
243/// Re-run the test file in verbose mode so the entire stderr is ready to be
244/// copied to the clipboard (used by `test:all` for quick debugging).
245pub fn run_single_file_verbose(fname: &str) -> Result<String, String> {
246    let output = std::process::Command::new("lava")
247        .arg("-v")
248        .arg(fname)
249        .output()
250        .map_err(|e| e.to_string())?;
251
252    Ok(String::from_utf8_lossy(&output.stderr).to_string())
253}
254
255/// Bridging function exposed as `test:run(<filename>, <callback>)`.
256pub fn runner_run_bridge(interp: &mut Interpreter, args: Vec<Value>) -> Result<Value, String> {
257    if args.len() != 2 {
258        return Err(format!("test:run => expected 2 args, got {}", args.len()));
259    }
260
261    let fname = match &args[0] {
262        Value::SingleString(s) => s.clone(),
263        _ => return Err("test:run => first arg must be a string (filename)".into()),
264    };
265    let cb = args[1].clone();
266
267    let (_pass, output, _report) = run_single_file_with_report(interp, &fname, &cb, true)?;
268    print!("{}", output);
269
270    Ok(Value::Bool(true))
271}