test-mumu 0.1.10

Test suite plugin for the Lava language
Documentation
// src/runner/run_single.rs
use mumu::parser::interpreter::Interpreter;
use mumu::parser::types::{FunctionValue, Value};
use serde::Deserialize;
use serde_json::from_str;
use std::fmt::Write as FmtWrite;
use std::process::Command;

use super::helper::{CloneFunction, HRULE, HRULE_PLAIN};

#[derive(Deserialize)]
pub struct JsonTestEntry {
    pub name: String,
    pub passed: bool,
    pub time_us: i64,
    pub output: String,
}

#[derive(Deserialize)]
pub struct JsonFileReport {
    pub suite: String,
    pub tests: Vec<JsonTestEntry>,
}

#[derive(Clone)]
pub struct TestEntry {
    pub name: String,
    pub passed: bool,
    pub time_us: i64,
    pub output: String,
}

#[derive(Clone)]
pub struct FileReport {
    pub suite: String,
    pub tests: Vec<TestEntry>,
}

/// Local helper to call a callback with arguments.
/// Supports both **named** and **inline** functions (via apply).
fn call_named_or_inline_callback(interp: &mut Interpreter, cb: &Value, args: Vec<Value>) {
    if let Some(fb) = cb.clone_function() {
        match *fb {
            FunctionValue::Named(ref name) => {
                if let Some(df) = interp.get_dynamic_function(name) {
                    let _ = (df.lock().unwrap())(interp, args);
                } else {
                    let _ = mumu::parser::interpreter::apply::apply_function_value(
                        interp,
                        fb,
                        args,
                    );
                }
            }
            _ => {
                let _ = mumu::parser::interpreter::apply::apply_function_value(interp, fb, args);
            }
        }
    }
}

/// Execute one test file with the system‐wide `lava` binary and collect:
/// 1. A boolean indicating whether *all* of its tests passed.
/// 2. A pretty–printed console report (colourised when requested).
/// 3. A flattened `FileReport` that contains **all** individual test results from
///    *every* `describe` block inside the file (used by `test:all` when
///    `errors_only=true` so that we never lose information about failing
///    assertions).
pub fn run_single_file_with_report(
    interp: &mut Interpreter,
    fname: &str,
    cb: &Value,
    colorize: bool,
) -> Result<(bool, String, FileReport), String> {
    //---------------------------------------------------------------------
    // 1. Spawn `lava <file>` and capture its stdout/stderr
    //---------------------------------------------------------------------
    let output = Command::new("lava").arg(fname).output().map_err(|e| e.to_string())?;

    let stdout_str = String::from_utf8_lossy(&output.stdout);
    let stderr_str = String::from_utf8_lossy(&output.stderr);

    //---------------------------------------------------------------------
    // 2. Parse every JSON line that the file emitted
    //---------------------------------------------------------------------
    let mut per_suite_reports: Vec<FileReport> = Vec::new();

    if output.status.success() {
        for line in stdout_str.lines().filter(|l| !l.trim().is_empty()) {
            if let Ok(js) = from_str::<JsonFileReport>(line) {
                // Map JSON -> internal, but **treat empty test lists as a failure**
                let mut mapped_tests: Vec<TestEntry> = js
                    .tests
                    .into_iter()
                    .map(|t| TestEntry {
                        name: t.name,
                        passed: t.passed,
                        time_us: t.time_us,
                        output: t.output,
                    })
                    .collect();

                if mapped_tests.is_empty() {
                    mapped_tests.push(TestEntry {
                        name: format!("{}: no tests discovered", js.suite),
                        passed: false,
                        time_us: 0,
                        output: "Suite produced no tests (did the describe body run?)".into(),
                    });
                }

                per_suite_reports.push(FileReport {
                    suite: js.suite,
                    tests: mapped_tests,
                });
            }
        }

        // If the file produced no valid JSON we still want to mark it as a
        // failure so users notice immediately.
        if per_suite_reports.is_empty() {
            per_suite_reports.push(FileReport {
                suite: fname.to_string(),
                tests: vec![TestEntry {
                    name: fname.to_string(),
                    passed: false,
                    time_us: 0,
                    output: "No valid JSON output".into(),
                }],
            });
        }
    } else {
        // Lava exited with a non-zero status – treat this entire file as a single
        // failing “test”.
        per_suite_reports.push(FileReport {
            suite: fname.to_string(),
            tests: vec![TestEntry {
                name: fname.to_string(),
                passed: false,
                time_us: 0,
                output: stderr_str.into(),
            }],
        });
    }

    //---------------------------------------------------------------------
    // 3. Determine overall pass/fail for the *file*
    //---------------------------------------------------------------------
    let file_pass = per_suite_reports.iter().all(|r| r.tests.iter().all(|t| t.passed));

    //---------------------------------------------------------------------
    // 4. Build *flattened* report (all tests from all suites)
    //---------------------------------------------------------------------
    let mut flattened_tests = Vec::new();
    for r in &per_suite_reports {
        flattened_tests.extend(r.tests.clone());
    }
    let flattened_report = FileReport {
        suite: fname.to_string(),
        tests: flattened_tests,
    };

    //---------------------------------------------------------------------
    // 5. Pretty console output
    //---------------------------------------------------------------------
    let mut out = String::new();

    if colorize {
        writeln!(out, "{HRULE}").unwrap();
        if file_pass {
            writeln!(out, "\x1b[1;32m{}\x1b[0m", fname).unwrap();
        } else {
            writeln!(out, "\x1b[1;31m{}\x1b[0m", fname).unwrap();
        }
    } else {
        writeln!(out, "{HRULE_PLAIN}").unwrap();
        writeln!(out, "{}", fname).unwrap();
    }
    writeln!(out).unwrap();

    let mut first_suite = true;
    for suite in &per_suite_reports {
        if !first_suite {
            writeln!(out).unwrap();
        }
        first_suite = false;

        let suite_pass = suite.tests.iter().all(|t| t.passed);

        if colorize {
            if suite_pass {
                writeln!(out, "\x1b[1m{}\x1b[0m", suite.suite).unwrap();
            } else {
                writeln!(out, "\x1b[1;31m{}\x1b[0m", suite.suite).unwrap();
            }
        } else {
            writeln!(out, "{}", suite.suite).unwrap();
        }

        for t in &suite.tests {
            if t.passed {
                if colorize {
                    writeln!(out, "\x1b[32m✔ {}\x1b[0m ({} µs)", t.name, t.time_us).unwrap();
                } else {
                    writeln!(out, "{} ({} µs)", t.name, t.time_us).unwrap();
                }
            } else {
                // ---------- failing test ----------
                if colorize {
                    writeln!(out, "\x1b[31m✖ {}\x1b[0m ({} µs)", t.name, t.time_us).unwrap();
                } else {
                    writeln!(out, "{} ({} µs)", t.name, t.time_us).unwrap();
                }
                writeln!(out).unwrap();
                for line in t.output.lines() {
                    writeln!(out, "  {}", line).unwrap();
                }
                writeln!(out).unwrap();
            }
        }

        // Fire user callback with the per-suite result (best-effort)
        call_named_or_inline_callback(interp, cb, vec![Value::Bool(suite_pass)]);
    }

    if colorize {
        if !file_pass {
            writeln!(out).unwrap();
            writeln!(out, "\x1b[1;31mFAILED\x1b[0m").unwrap();
        }
        writeln!(out).unwrap();
    } else {
        if !file_pass {
            writeln!(out).unwrap();
            writeln!(out, "FAILED").unwrap();
        }
        writeln!(out).unwrap();
    }

    Ok((file_pass, out, flattened_report))
}

/// Re-run the test file in verbose mode so the entire stderr is ready to be
/// copied to the clipboard (used by `test:all` for quick debugging).
pub fn run_single_file_verbose(fname: &str) -> Result<String, String> {
    let output = std::process::Command::new("lava")
        .arg("-v")
        .arg(fname)
        .output()
        .map_err(|e| e.to_string())?;

    Ok(String::from_utf8_lossy(&output.stderr).to_string())
}

/// Bridging function exposed as `test:run(<filename>, <callback>)`.
pub fn runner_run_bridge(interp: &mut Interpreter, args: Vec<Value>) -> Result<Value, String> {
    if args.len() != 2 {
        return Err(format!("test:run => expected 2 args, got {}", args.len()));
    }

    let fname = match &args[0] {
        Value::SingleString(s) => s.clone(),
        _ => return Err("test:run => first arg must be a string (filename)".into()),
    };
    let cb = args[1].clone();

    let (_pass, output, _report) = run_single_file_with_report(interp, &fname, &cb, true)?;
    print!("{}", output);

    Ok(Value::Bool(true))
}