1use 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
17pub const DEFAULT_LIB: &str = "htl.test";
19
20pub 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 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#[derive(Debug, Default)]
40pub struct FileReport {
41 pub path: PathBuf,
42 pub check: CheckInfo,
43 pub error: Option<String>,
45 pub passed: usize,
46 pub failed: usize,
47 pub failures: Vec<String>,
48 pub file_level: bool,
50 pub tests: Vec<TestResult>,
52 pub duration_ms: f64,
54 pub snapshots_written: Vec<String>,
56 pub snapshots_updated: Vec<String>,
58 pub coverage: Vec<(String, Vec<usize>)>,
60}
61
62#[derive(Debug, Clone, Default)]
64pub struct TestResult {
65 pub name: String,
66 pub ok: bool,
67 pub ms: f64,
68}
69
70#[derive(Debug, Clone, Default)]
72pub struct RunOptions {
73 pub fail_fast: bool,
75 pub update_snapshots: bool,
77 pub coverage: bool,
79 pub seed: Option<u64>,
84}
85
86pub 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
103pub 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
118pub fn discover_tests(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
122 discover_tests_skipping(paths, &[])
123}
124
125pub 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
164pub 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
176pub 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 pub fn checker(&self) -> &Htl {
207 &self.checker
208 }
209
210 pub fn run_file(&self, path: &Path) -> Result<FileReport> {
214 self.run_file_with(path, None, &[]).map(|(rep, _)| rep)
215 }
216
217 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
262struct RunIn<'a> {
264 filter: Option<&'a str>,
265 lib: &'a str,
266 opts: &'a RunOptions,
267 generated: Option<(&'a str, &'a CheckInfo)>,
269 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 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 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 *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 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 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 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}