Skip to main content

htl_core/
testing.rs

1//! `htl test`: discovery, one isolated Lua state per test file, compile, report.
2//!
3//! The runner owns nothing about assertions. A test file `require`s a library; the
4//! bundled default is `htl.test` (`describe` / `it` / `expect`, typed via `test.d.tl`).
5//! Any library exposing `run(filter) -> { passed, failed, failures }` under the
6//! module name the runner is told about plugs in the same way. A file that uses no
7//! such library is judged at file level: it passes if it runs to completion.
8
9use crate::{CheckInfo, Htl, parent_dir, write_if_changed};
10use anyhow::{Context, Result};
11use mlua::{Function, Table, Value};
12use std::path::{Path, PathBuf};
13
14const TEST_LUA: &str = include_str!("../lua/test.lua");
15const TEST_DTL: &str = include_str!("../lua/test.d.tl");
16
17/// Module name of the bundled assertion library.
18pub const DEFAULT_LIB: &str = "htl.test";
19
20/// Directory holding the bundled `.d.tl` files so the checker can see them
21/// (`<tmp>/htl-lib-<version>/`). Written on demand, only when content changes.
22pub fn lib_dir() -> Result<PathBuf> {
23    let dir = std::env::temp_dir().join(format!("htl-lib-{}", env!("CARGO_PKG_VERSION")));
24    write_if_changed(&dir.join("htl").join("test.d.tl"), TEST_DTL)
25        .with_context(|| format!("writing bundled declarations under {}", dir.display()))?;
26    Ok(dir)
27}
28
29impl Htl {
30    /// Make `require("htl.test")` work at runtime and its types visible to the checker.
31    pub fn install_test_lib(&self) -> Result<()> {
32        self.preload(DEFAULT_LIB, TEST_LUA)?;
33        self.add_path(&lib_dir()?)?;
34        Ok(())
35    }
36}
37
38/// Outcome of one test file.
39#[derive(Debug, Default)]
40pub struct FileReport {
41    pub path: PathBuf,
42    pub check: CheckInfo,
43    /// Runtime error outside any test (e.g. the file itself raised).
44    pub error: Option<String>,
45    pub passed: usize,
46    pub failed: usize,
47    pub failures: Vec<String>,
48    /// `true` when no test library was used and the verdict is file-level.
49    pub file_level: bool,
50    /// Per-test outcomes when the library reports them (`htl.test` does).
51    pub tests: Vec<TestResult>,
52    /// Wall time for the whole file: check, load, and every test.
53    pub duration_ms: f64,
54}
55
56/// One test's outcome, as reported by the assertion library.
57#[derive(Debug, Clone, Default)]
58pub struct TestResult {
59    pub name: String,
60    pub ok: bool,
61    pub ms: f64,
62}
63
64/// Runner options passed through to the library's `run(filter, opts)`.
65#[derive(Debug, Clone, Default)]
66pub struct RunOptions {
67    /// Stop at the first failing test in a file.
68    pub fail_fast: bool,
69}
70
71impl FileReport {
72    pub fn ok(&self) -> bool {
73        self.check.ok() && self.error.is_none() && self.failed == 0
74    }
75}
76
77/// `*_test.tl` anywhere, plus every `.tl` under a directory named `tests`.
78/// Explicit file paths are always included. Does not enter [`crate::SKIP_DIRS`],
79/// dot-directories or the project's mlua-pkg dir (dependencies' tests are theirs).
80pub fn discover_tests(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
81    let mut out = Vec::new();
82    for p in paths {
83        if p.is_file() {
84            out.push(p.clone());
85            continue;
86        }
87        let extra = crate::project_skip_dirs(p);
88        let root = p.clone();
89        let walker = walkdir::WalkDir::new(p)
90            .sort_by_file_name()
91            .into_iter()
92            .filter_entry(move |e| e.path() == root || !crate::is_skipped_dir(e.path(), &extra));
93        for e in walker {
94            let e = e?;
95            let path = e.path();
96            if !crate::is_tl_source(path) {
97                continue;
98            }
99            let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
100            let in_tests_dir = path
101                .strip_prefix(p)
102                .ok()
103                .map(|rel| rel.components().any(|c| c.as_os_str() == "tests"))
104                .unwrap_or(false);
105            if name.ends_with("_test.tl") || in_tests_dir {
106                out.push(path.to_path_buf());
107            }
108        }
109    }
110    out.sort();
111    out.dedup();
112    Ok(out)
113}
114
115/// Run one test file in a fresh state. `lib` is the assertion library module to
116/// consult for `run(filter)` after the file executed (default: `htl.test`).
117pub fn run_test_file(
118    path: &Path,
119    filter: Option<&str>,
120    lib: &str,
121    lint_spec: Option<&str>,
122    opts: &RunOptions,
123) -> Result<FileReport> {
124    TestSession::new(lint_spec, lib, filter, opts.clone())?.run_file(path)
125}
126
127/// One checker for a whole run: every test file gets its own fresh program state
128/// (globals, `package.loaded`, module state), but modules are type-checked and
129/// generated once and served to every file from the checker's store.
130pub struct TestSession {
131    checker: Htl,
132    lib: String,
133    filter: Option<String>,
134    opts: RunOptions,
135}
136
137impl TestSession {
138    pub fn new(lint_spec: Option<&str>, lib: &str, filter: Option<&str>, opts: RunOptions) -> Result<Self> {
139        let checker = Htl::new()?;
140        if let Some(spec) = lint_spec {
141            checker.configure_lints(spec)?;
142        }
143        Ok(Self { checker, lib: lib.to_string(), filter: filter.map(String::from), opts })
144    }
145
146    /// Run one file in a fresh program state borrowing the session's checker. The
147    /// checker's search path is restored afterwards so files do not see each other's
148    /// directories.
149    pub fn run_file(&self, path: &Path) -> Result<FileReport> {
150        let started = std::time::Instant::now();
151        let saved = self.checker.search_path()?;
152        let h = Htl::with_checker(&self.checker)?;
153        let out = run_in(&h, path, self.filter.as_deref(), &self.lib, &self.opts);
154        self.checker.set_search_path(&saved)?;
155        let mut rep = out?;
156        rep.duration_ms = started.elapsed().as_secs_f64() * 1000.0;
157        Ok(rep)
158    }
159}
160
161fn run_in(h: &Htl, path: &Path, filter: Option<&str>, lib: &str, opts: &RunOptions) -> Result<FileReport> {
162    let mut rep = FileReport { path: path.to_path_buf(), ..Default::default() };
163    let profile = std::env::var_os("HTL_PROFILE").is_some();
164    let mut t0 = std::time::Instant::now();
165    let phase = |label: &str, t0: &mut std::time::Instant| {
166        if profile {
167            eprintln!("profile: {label:<8} {:7.1} ms  {}", t0.elapsed().as_secs_f64() * 1000.0, path.display());
168        }
169        *t0 = std::time::Instant::now();
170    };
171    phase("state", &mut t0);
172    h.install_test_lib()?;
173    let dir = parent_dir(path);
174    h.add_path(&dir)?;
175    #[cfg(feature = "pkg")]
176    if let Some(p) = crate::pkg::Project::find(path) {
177        h.apply_project(&p)?;
178    }
179    if let Some((cfg_path, cfg)) = crate::config::HtlConfig::find(path)? {
180        h.apply_config(&parent_dir(&cfg_path), &cfg)?;
181    }
182    // tests/foo_test.tl commonly requires modules from the project root or src/.
183    if dir.file_name().is_some_and(|n| n == "tests")
184        && let Some(root) = dir.parent()
185    {
186        h.add_path(root)?;
187        h.add_path(&root.join("src"))?;
188    }
189    h.install_searcher()?;
190    h.set_arg(&path.to_string_lossy(), &[])?;
191    phase("setup", &mut t0);
192
193    let (code, check) = h.gen_lua(path)?;
194    phase("gen_lua", &mut t0);
195    rep.check = check;
196    let Some(code) = code else { return Ok(rep) };
197    if let Err(e) = h.exec(&code, &format!("@{}", path.display()), &[]) {
198        rep.error = Some(crate::user_message(&e));
199        return Ok(rep);
200    }
201    phase("exec", &mut t0);
202
203    // Did the file load the assertion library? Then ask it for the verdict.
204    let package: Table = h.lua().globals().get("package")?;
205    let loaded: Table = package.get("loaded")?;
206    match loaded.get::<Value>(lib)? {
207        Value::Table(t) => {
208            let run: Function = t.get("run")?;
209            let lua_opts = h.lua().create_table()?;
210            lua_opts.set("fail_fast", opts.fail_fast)?;
211            let report: Table = run.call((filter, lua_opts))?;
212            phase("run", &mut t0);
213            rep.passed = report.get::<Option<usize>>("passed")?.unwrap_or(0);
214            rep.failed = report.get::<Option<usize>>("failed")?.unwrap_or(0);
215            if let Ok(f) = report.get::<Table>("failures") {
216                rep.failures = f.sequence_values::<String>().collect::<mlua::Result<_>>()?;
217            }
218            if let Ok(tests) = report.get::<Table>("tests") {
219                for tr in tests.sequence_values::<Table>() {
220                    let tr = tr?;
221                    rep.tests.push(TestResult {
222                        name: tr.get::<Option<String>>("name")?.unwrap_or_default(),
223                        ok: tr.get::<Option<bool>>("ok")?.unwrap_or(false),
224                        ms: tr.get::<Option<f64>>("ms")?.unwrap_or(0.0),
225                    });
226                }
227            }
228        }
229        _ => {
230            rep.file_level = true;
231            rep.passed = 1;
232        }
233    }
234    Ok(rep)
235}