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    /// Snapshot files created on this run (first `to_match_snapshot` of a name).
55    pub snapshots_written: Vec<String>,
56    /// Snapshot files rewritten because `update_snapshots` was set.
57    pub snapshots_updated: Vec<String>,
58    /// With `coverage`: `(chunk source as Lua names it, executed lines)`.
59    pub coverage: Vec<(String, Vec<usize>)>,
60}
61
62/// One test's outcome, as reported by the assertion library.
63#[derive(Debug, Clone, Default)]
64pub struct TestResult {
65    pub name: String,
66    pub ok: bool,
67    pub ms: f64,
68}
69
70/// Runner options passed through to the library's `run(filter, opts)`.
71#[derive(Debug, Clone, Default)]
72pub struct RunOptions {
73    /// Stop at the first failing test in a file.
74    pub fail_fast: bool,
75    /// Rewrite snapshots that differ instead of failing (`htl test --update`).
76    pub update_snapshots: bool,
77    /// Record executed lines per chunk while the file runs (`htl test --coverage`).
78    pub coverage: bool,
79    /// Seed for the run (`htl test --seed`). Each file draws from a stream derived from
80    /// this and its own path, so one file's values do not depend on which other files ran
81    /// or in what order: `--filter` reproduces what the full run did, and a failure can be
82    /// looked at again on its own. `None` leaves the state's own seeding alone.
83    pub seed: Option<u64>,
84}
85
86/// The seed one file gets, from the run's seed and its path.
87///
88/// Derived rather than taken from a shared stream, so the answer to "what did this file
89/// draw" does not depend on the company it kept. SplitMix64 over an FNV-1a of the path:
90/// the point is that neighbouring paths land far apart, not that it is unguessable.
91pub fn file_seed(run_seed: u64, path: &Path) -> u64 {
92    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
93    for b in path.to_string_lossy().as_bytes() {
94        h ^= *b as u64;
95        h = h.wrapping_mul(0x0000_0100_0000_01b3);
96    }
97    let mut z = run_seed.wrapping_add(h).wrapping_add(0x9e37_79b9_7f4a_7c15);
98    z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
99    z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
100    z ^ (z >> 31)
101}
102
103/// Where a test file's snapshots live: `<dir>/__snapshots__/<file stem>/`.
104pub fn snapshot_dir(test_file: &Path) -> PathBuf {
105    let stem = test_file
106        .file_stem()
107        .and_then(|s| s.to_str())
108        .unwrap_or("test");
109    parent_dir(test_file).join("__snapshots__").join(stem)
110}
111
112impl FileReport {
113    pub fn ok(&self) -> bool {
114        self.check.ok() && self.error.is_none() && self.failed == 0
115    }
116}
117
118/// `*_test.tl` anywhere, plus every `.tl` under a directory named `tests`.
119/// Explicit file paths are always included. Does not enter [`crate::SKIP_DIRS`],
120/// dot-directories or the project's mlua-pkg dir (dependencies' tests are theirs).
121pub fn discover_tests(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
122    discover_tests_skipping(paths, &[])
123}
124
125/// [`discover_tests`], not entering `skip` either — directories named by path rather than
126/// by name ([`crate::patched_dirs`]: a patched dependency's `*_test.tl` are its own suite,
127/// not the project's).
128pub fn discover_tests_skipping(paths: &[PathBuf], skip: &[PathBuf]) -> Result<Vec<PathBuf>> {
129    let mut out = Vec::new();
130    for p in paths {
131        if p.is_file() {
132            out.push(p.clone());
133            continue;
134        }
135        let mut extra = crate::project_skip_dirs(p);
136        extra.extend(skip.iter().cloned());
137        let root = p.clone();
138        let walker = walkdir::WalkDir::new(p)
139            .sort_by_file_name()
140            .into_iter()
141            .filter_entry(move |e| e.path() == root || !crate::is_skipped_dir(e.path(), &extra));
142        for e in walker {
143            let e = e?;
144            let path = e.path();
145            if !crate::is_tl_source(path) {
146                continue;
147            }
148            let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
149            let in_tests_dir = path
150                .strip_prefix(p)
151                .ok()
152                .map(|rel| rel.components().any(|c| c.as_os_str() == "tests"))
153                .unwrap_or(false);
154            if name.ends_with("_test.tl") || in_tests_dir {
155                out.push(path.to_path_buf());
156            }
157        }
158    }
159    out.sort();
160    out.dedup();
161    Ok(out)
162}
163
164/// Run one test file in a fresh state. `lib` is the assertion library module to
165/// consult for `run(filter)` after the file executed (default: `htl.test`).
166pub fn run_test_file(
167    path: &Path,
168    filter: Option<&str>,
169    lib: &str,
170    lint_spec: Option<&str>,
171    opts: &RunOptions,
172) -> Result<FileReport> {
173    TestSession::new(lint_spec, lib, filter, opts.clone())?.run_file(path)
174}
175
176/// One checker for a whole run: every test file gets its own fresh program state
177/// (globals, `package.loaded`, module state), but modules are type-checked and
178/// generated once and served to every file from the checker's store.
179pub struct TestSession {
180    checker: Htl,
181    lib: String,
182    filter: Option<String>,
183    opts: RunOptions,
184}
185
186impl TestSession {
187    pub fn new(
188        lint_spec: Option<&str>,
189        lib: &str,
190        filter: Option<&str>,
191        opts: RunOptions,
192    ) -> Result<Self> {
193        let checker = Htl::new()?;
194        if let Some(spec) = lint_spec {
195            checker.configure_lints(spec)?;
196        }
197        Ok(Self {
198            checker,
199            lib: lib.to_string(),
200            filter: filter.map(String::from),
201            opts,
202        })
203    }
204
205    /// The session's checker (for [`Htl::executable_ranges`] on the sources a run touched).
206    pub fn checker(&self) -> &Htl {
207        &self.checker
208    }
209
210    /// Run one file in a fresh program state borrowing the session's checker. The
211    /// checker's search path is restored afterwards so files do not see each other's
212    /// directories.
213    pub fn run_file(&self, path: &Path) -> Result<FileReport> {
214        self.run_file_with(path, None, &[]).map(|(rep, _)| rep)
215    }
216
217    /// Run one file, reusing Lua the caller generated earlier.
218    ///
219    /// `generated` is `(the Lua, what checking it reported)`. Everything before the codegen
220    /// still happens — the searcher, the search path, the project and the config all have to
221    /// be in place before the code can execute — and everything after it happens as usual.
222    /// Only the check and the codegen are skipped.
223    ///
224    /// The run itself is never reused, and this signature cannot express reusing it: a test
225    /// has to run to say whether it passes.
226    ///
227    /// Returns the report and, when this call generated the Lua rather than being handed it,
228    /// that Lua — so a caller keeping a cache has something to keep.
229    /// `preload` is `(module name, its generated Lua, the file it came from)` for modules
230    /// this file will require. Each one goes in front of the searcher, so requiring it does
231    /// not check and generate it during the run. A module not in the list still loads the
232    /// usual way; the list is an optimisation, never a restriction on what can be required.
233    pub fn run_file_with(
234        &self,
235        path: &Path,
236        generated: Option<(&str, &CheckInfo)>,
237        preload: &[(String, String, PathBuf)],
238    ) -> Result<(FileReport, Option<String>)> {
239        let started = std::time::Instant::now();
240        let saved = self.checker.search_path()?;
241        let h = Htl::with_checker(&self.checker)?;
242        let mut code = None;
243        let out = run_in(
244            &h,
245            path,
246            RunIn {
247                filter: self.filter.as_deref(),
248                lib: &self.lib,
249                opts: &self.opts,
250                generated,
251                preload,
252            },
253            &mut code,
254        );
255        self.checker.set_search_path(&saved)?;
256        let mut rep = out?;
257        rep.duration_ms = started.elapsed().as_secs_f64() * 1000.0;
258        Ok((rep, code))
259    }
260}
261
262/// What one file's run needs from its session, and what the caller already has for it.
263struct RunIn<'a> {
264    filter: Option<&'a str>,
265    lib: &'a str,
266    opts: &'a RunOptions,
267    /// Lua and diagnostics from an earlier run, when the caller kept them.
268    generated: Option<(&'a str, &'a CheckInfo)>,
269    /// Modules to put in front of the searcher before the file executes.
270    preload: &'a [(String, String, PathBuf)],
271}
272
273fn run_in(h: &Htl, path: &Path, r: RunIn<'_>, out_code: &mut Option<String>) -> Result<FileReport> {
274    let RunIn {
275        filter,
276        lib,
277        opts,
278        generated,
279        preload,
280    } = r;
281    let mut rep = FileReport {
282        path: path.to_path_buf(),
283        ..Default::default()
284    };
285    let profile = std::env::var_os("HTL_PROFILE").is_some();
286    let mut t0 = std::time::Instant::now();
287    let phase = |label: &str, t0: &mut std::time::Instant| {
288        if profile {
289            eprintln!(
290                "profile: {label:<8} {:7.1} ms  {}",
291                t0.elapsed().as_secs_f64() * 1000.0,
292                path.display()
293            );
294        }
295        *t0 = std::time::Instant::now();
296    };
297    phase("state", &mut t0);
298    h.install_test_lib()?;
299    let dir = parent_dir(path);
300    h.add_path(&dir)?;
301    #[cfg(feature = "pkg")]
302    if let Some(p) = crate::pkg::Project::find(path) {
303        h.apply_project(&p)?;
304    }
305    if let Some((cfg_path, cfg)) = crate::config::HtlConfig::find(path)? {
306        h.apply_config(&parent_dir(&cfg_path), &cfg)?;
307    }
308    // tests/foo_test.tl commonly requires modules from the project root or src/.
309    if dir.file_name().is_some_and(|n| n == "tests")
310        && let Some(root) = dir.parent()
311    {
312        h.add_path(root)?;
313        h.add_path(&root.join("src"))?;
314    }
315    h.install_searcher()?;
316    // Before the searcher gets a chance to be asked. Position 1 beats position 2.
317    for (name, code, from) in preload {
318        h.preload_generated(name, code, from)?;
319    }
320    h.set_arg(&path.to_string_lossy(), &[])?;
321    phase("setup", &mut t0);
322
323    let (code, check) = match generated {
324        Some((code, check)) => (Some(code.to_string()), check.clone()),
325        None => {
326            let (code, check) = h.gen_lua(path)?;
327            // Hand the caller what was generated, before any of the early returns below: a
328            // file whose tests fail still generated the Lua that failed, and the next run
329            // should not have to generate it again to find that out.
330            *out_code = code.clone();
331            (code, check)
332        }
333    };
334    phase("gen_lua", &mut t0);
335    rep.check = check;
336    let Some(code) = code else { return Ok(rep) };
337    // Before the file runs, so a module it requires draws from the same stream. Seeding
338    // the state's own generator rather than handing out a private one means a test that
339    // already calls `math.random` becomes reproducible without being rewritten.
340    if let Some(run_seed) = opts.seed {
341        let math: Table = h.lua().globals().get("math")?;
342        let randomseed: Function = math.get("randomseed")?;
343        randomseed.call::<()>(file_seed(run_seed, path) as i64)?;
344    }
345    if opts.coverage {
346        h.coverage_start()?;
347    }
348    if let Err(e) = h.exec(&code, &format!("@{}", path.display()), &[]) {
349        rep.error = Some(crate::user_message(&e));
350        if opts.coverage {
351            rep.coverage = h.coverage_stop()?;
352        }
353        return Ok(rep);
354    }
355    phase("exec", &mut t0);
356
357    // Did the file load the assertion library? Then ask it for the verdict.
358    let package: Table = h.lua().globals().get("package")?;
359    let loaded: Table = package.get("loaded")?;
360    match loaded.get::<Value>(lib)? {
361        Value::Table(t) => {
362            // Snapshots: tell the library where this file's live and whether to
363            // rewrite them. Lua cannot create a directory, so it borrows one.
364            if let Ok(configure) = t.get::<Function>("configure") {
365                let cfg = h.lua().create_table()?;
366                cfg.set(
367                    "snapshot_dir",
368                    snapshot_dir(path).to_string_lossy().as_ref(),
369                )?;
370                cfg.set("update", opts.update_snapshots)?;
371                cfg.set(
372                    "mkdir",
373                    h.lua().create_function(|_, dir: String| {
374                        std::fs::create_dir_all(&dir).map_err(mlua::Error::external)
375                    })?,
376                )?;
377                configure.call::<()>(cfg)?;
378            }
379            let run: Function = t.get("run")?;
380            let lua_opts = h.lua().create_table()?;
381            lua_opts.set("fail_fast", opts.fail_fast)?;
382            let report: Table = run.call((filter, lua_opts))?;
383            for (key, into) in [
384                ("snapshots_written", &mut rep.snapshots_written),
385                ("snapshots_updated", &mut rep.snapshots_updated),
386            ] {
387                if let Ok(list) = report.get::<Table>(key) {
388                    *into = list
389                        .sequence_values::<String>()
390                        .collect::<mlua::Result<_>>()?;
391                }
392            }
393            phase("run", &mut t0);
394            rep.passed = report.get::<Option<usize>>("passed")?.unwrap_or(0);
395            rep.failed = report.get::<Option<usize>>("failed")?.unwrap_or(0);
396            if let Ok(f) = report.get::<Table>("failures") {
397                rep.failures = f.sequence_values::<String>().collect::<mlua::Result<_>>()?;
398            }
399            if let Ok(tests) = report.get::<Table>("tests") {
400                for tr in tests.sequence_values::<Table>() {
401                    let tr = tr?;
402                    rep.tests.push(TestResult {
403                        name: tr.get::<Option<String>>("name")?.unwrap_or_default(),
404                        ok: tr.get::<Option<bool>>("ok")?.unwrap_or(false),
405                        ms: tr.get::<Option<f64>>("ms")?.unwrap_or(0.0),
406                    });
407                }
408            }
409        }
410        _ => {
411            rep.file_level = true;
412            rep.passed = 1;
413        }
414    }
415    if opts.coverage {
416        rep.coverage = h.coverage_stop()?;
417    }
418    Ok(rep)
419}