use crate::{CheckInfo, Htl, parent_dir, write_if_changed};
use anyhow::{Context, Result};
use mlua::{Function, Table, Value};
use std::path::{Path, PathBuf};
const TEST_LUA: &str = include_str!("../lua/test.lua");
const TEST_DTL: &str = include_str!("../lua/test.d.tl");
pub const DEFAULT_LIB: &str = "htl.test";
pub(crate) fn declarations() -> Vec<(String, String)> {
vec![("htl/test.d.tl".to_string(), TEST_DTL.to_string())]
}
pub fn lib_dir() -> Result<PathBuf> {
let dir = crate::lib_dir();
for (path, source) in declarations() {
write_if_changed(&dir.join(path), &source)
.with_context(|| format!("writing bundled declarations under {}", dir.display()))?;
}
Ok(dir)
}
impl Htl {
pub fn install_test_lib(&self) -> Result<()> {
self.preload_at(DEFAULT_LIB, &format!("={DEFAULT_LIB}"), TEST_LUA)?;
self.add_path(&lib_dir()?)?;
Ok(())
}
}
#[derive(Debug, Default, Clone)]
pub struct FileReport {
pub path: PathBuf,
pub check: CheckInfo,
pub error: Option<String>,
pub passed: usize,
pub failed: usize,
pub failures: Vec<String>,
pub file_level: bool,
pub tests: Vec<TestResult>,
pub duration_ms: f64,
pub snapshots_written: Vec<String>,
pub snapshots_updated: Vec<String>,
pub coverage: Vec<(String, Vec<usize>)>,
}
#[derive(Debug, Clone, Default)]
pub struct TestResult {
pub name: String,
pub ok: bool,
pub ms: f64,
}
#[derive(Debug, Clone, Default)]
pub struct RunOptions {
pub fail_fast: bool,
pub update_snapshots: bool,
pub coverage: bool,
pub seed: Option<u64>,
}
pub fn file_seed(run_seed: u64, path: &Path) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in path.to_string_lossy().as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
let mut z = run_seed.wrapping_add(h).wrapping_add(0x9e37_79b9_7f4a_7c15);
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
pub fn snapshot_dir(test_file: &Path) -> PathBuf {
let stem = test_file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("test");
parent_dir(test_file).join("__snapshots__").join(stem)
}
impl FileReport {
pub fn ok(&self) -> bool {
self.check.ok() && self.error.is_none() && self.failed == 0
}
}
pub fn discover_tests(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
discover_tests_skipping(paths, &[])
}
pub fn discover_tests_skipping(paths: &[PathBuf], skip: &[PathBuf]) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
for p in paths {
if p.is_file() {
out.push(p.clone());
continue;
}
let mut extra = crate::project_skip_dirs(p);
extra.extend(skip.iter().cloned());
let root = p.clone();
let walker = walkdir::WalkDir::new(p)
.sort_by_file_name()
.into_iter()
.filter_entry(move |e| e.path() == root || !crate::is_skipped_dir(e.path(), &extra));
for e in walker {
let e = e?;
let path = e.path();
if !crate::is_tl_source(path) {
continue;
}
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
let in_tests_dir = path
.strip_prefix(p)
.ok()
.map(|rel| rel.components().any(|c| c.as_os_str() == "tests"))
.unwrap_or(false);
if name.ends_with("_test.tl") || in_tests_dir {
out.push(path.to_path_buf());
}
}
}
out.sort();
out.dedup();
Ok(out)
}
pub fn run_test_file(
path: &Path,
filter: Option<&str>,
lib: &str,
lint_spec: Option<&str>,
opts: &RunOptions,
) -> Result<FileReport> {
TestSession::new(lint_spec, lib, filter, opts.clone())?.run_file(path)
}
#[cfg(all(feature = "pkg", feature = "dts"))]
#[derive(Debug, Clone, Default)]
pub struct Suite {
pub filter: Option<String>,
pub lib: Option<String>,
pub lint: Option<String>,
pub run: RunOptions,
pub cache: bool,
}
#[cfg(all(feature = "pkg", feature = "dts"))]
#[derive(Debug)]
pub struct SuiteReport {
pub files: Vec<FileReport>,
pub diagnostics: Vec<crate::Diagnostic>,
pub passed: usize,
pub failed: usize,
pub files_with_errors: usize,
pub skipped: usize,
pub seed: u64,
pub duration_ms: f64,
pub coverage: Option<crate::project::CoverageReport>,
}
#[cfg(all(feature = "pkg", feature = "dts"))]
impl SuiteReport {
pub fn ok(&self) -> bool {
self.files_with_errors == 0
}
pub fn failures(&self) -> Vec<String> {
let mut out = Vec::new();
for f in self.files.iter().filter(|f| !f.ok()) {
let at = f.path.display();
if !f.check.ok() {
out.extend(f.check.errors.iter().map(|e| format!("{at}: {e}")));
}
if let Some(e) = &f.error {
out.push(format!("{at}: {e}"));
}
out.extend(f.failures.iter().map(|m| format!("{at}: {m}")));
}
out
}
}
#[cfg(all(feature = "pkg", feature = "dts"))]
pub fn run_tests(paths: &[PathBuf], suite: &Suite) -> Result<SuiteReport> {
use crate::project;
let paths: Vec<PathBuf> = if paths.is_empty() {
vec![PathBuf::from(".")]
} else {
paths.to_vec()
};
let files = discover_tests_skipping(&paths, &project::patched(&paths))?;
let cfg = project::config_of(&paths[0])?;
let opts = project::TestOptions {
config: &cfg,
lint: suite.lint.as_deref(),
lib: suite.lib.as_deref().unwrap_or(DEFAULT_LIB),
filter: suite.filter.as_deref(),
run: suite.run.clone(),
cache: project::cache_options(
suite.cache,
Some(crate::cache::Mode::PerModule),
&cfg,
false,
),
};
let mut sink = project::Sink::new(project::Collect::default());
let mut reports: Vec<FileReport> = Vec::new();
let mut diagnostics: Vec<crate::Diagnostic> = Vec::new();
let rep = project::test(&mut sink, &files, &opts, &mut |r, sink| {
diagnostics.extend(sink.out().take());
reports.push(r.clone());
})?;
Ok(SuiteReport {
files: reports,
diagnostics,
passed: rep.passed,
failed: rep.failed,
files_with_errors: rep.files_with_errors,
skipped: rep.skipped(),
seed: rep.seed,
duration_ms: rep.duration_ms,
coverage: rep.coverage,
})
}
pub struct TestSession {
checker: Htl,
lib: String,
filter: Option<String>,
opts: RunOptions,
}
impl TestSession {
pub fn new(
lint_spec: Option<&str>,
lib: &str,
filter: Option<&str>,
opts: RunOptions,
) -> Result<Self> {
let checker = Htl::new()?;
if let Some(spec) = lint_spec {
checker.configure_lints(spec)?;
}
Ok(Self {
checker,
lib: lib.to_string(),
filter: filter.map(String::from),
opts,
})
}
pub fn checker(&self) -> &Htl {
&self.checker
}
pub fn run_file(&self, path: &Path) -> Result<FileReport> {
self.run_file_with(path, None, &[]).map(|(rep, _)| rep)
}
pub fn run_file_with(
&self,
path: &Path,
generated: Option<(&str, &CheckInfo)>,
preload: &[(String, String, PathBuf)],
) -> Result<(FileReport, Option<String>)> {
let started = std::time::Instant::now();
let saved = self.checker.search_path()?;
let h = Htl::with_checker(&self.checker)?;
let mut code = None;
let out = run_in(
&h,
path,
RunIn {
filter: self.filter.as_deref(),
lib: &self.lib,
opts: &self.opts,
generated,
preload,
},
&mut code,
);
self.checker.set_search_path(&saved)?;
let mut rep = out?;
rep.duration_ms = started.elapsed().as_secs_f64() * 1000.0;
Ok((rep, code))
}
}
struct RunIn<'a> {
filter: Option<&'a str>,
lib: &'a str,
opts: &'a RunOptions,
generated: Option<(&'a str, &'a CheckInfo)>,
preload: &'a [(String, String, PathBuf)],
}
fn run_in(h: &Htl, path: &Path, r: RunIn<'_>, out_code: &mut Option<String>) -> Result<FileReport> {
let RunIn {
filter,
lib,
opts,
generated,
preload,
} = r;
let mut rep = FileReport {
path: path.to_path_buf(),
..Default::default()
};
let profile = std::env::var_os("HTL_PROFILE").is_some();
let mut t0 = std::time::Instant::now();
let phase = |label: &str, t0: &mut std::time::Instant| {
if profile {
eprintln!(
"profile: {label:<8} {:7.1} ms {}",
t0.elapsed().as_secs_f64() * 1000.0,
path.display()
);
}
*t0 = std::time::Instant::now();
};
phase("state", &mut t0);
h.install_test_lib()?;
#[cfg(feature = "std")]
h.install_std()?;
let dir = parent_dir(path);
h.add_path(&dir)?;
#[cfg(feature = "pkg")]
if let Some(p) = crate::pkg::Project::find(path) {
h.apply_project(&p)?;
}
if let Some((cfg_path, cfg)) = crate::config::HtlConfig::find(path)? {
h.apply_config(&parent_dir(&cfg_path), &cfg)?;
}
if dir.file_name().is_some_and(|n| n == "tests")
&& let Some(root) = dir.parent()
{
h.add_path(root)?;
h.add_path(&root.join("src"))?;
}
h.install_searcher()?;
for (name, code, from) in preload {
h.preload_generated(name, code, from)?;
}
h.set_arg(&path.to_string_lossy(), &[])?;
phase("setup", &mut t0);
let (code, check) = match generated {
Some((code, check)) => (Some(code.to_string()), check.clone()),
None => {
let (code, check) = h.gen_lua(path)?;
*out_code = code.clone();
(code, check)
}
};
phase("gen_lua", &mut t0);
rep.check = check;
let Some(code) = code else { return Ok(rep) };
if let Some(run_seed) = opts.seed {
let math: Table = h.lua().globals().get("math")?;
let randomseed: Function = math.get("randomseed")?;
randomseed.call::<()>(file_seed(run_seed, path) as i64)?;
}
if opts.coverage {
h.coverage_start()?;
}
if let Err(e) = h.exec(&code, &format!("@{}", path.display()), &[]) {
rep.error = Some(crate::developer_message(&e));
if opts.coverage {
rep.coverage = h.coverage_stop()?;
}
return Ok(rep);
}
phase("exec", &mut t0);
let package: Table = h.lua().globals().get("package")?;
let loaded: Table = package.get("loaded")?;
match loaded.get::<Value>(lib)? {
Value::Table(t) => {
if let Ok(configure) = t.get::<Function>("configure") {
let cfg = h.lua().create_table()?;
cfg.set(
"snapshot_dir",
snapshot_dir(path).to_string_lossy().as_ref(),
)?;
cfg.set("update", opts.update_snapshots)?;
cfg.set(
"mkdir",
h.lua().create_function(|_, dir: String| {
std::fs::create_dir_all(&dir).map_err(mlua::Error::external)
})?,
)?;
configure.call::<()>(cfg)?;
}
let run: Function = t.get("run")?;
let lua_opts = h.lua().create_table()?;
lua_opts.set("fail_fast", opts.fail_fast)?;
let report: Table = run.call((filter, lua_opts))?;
for (key, into) in [
("snapshots_written", &mut rep.snapshots_written),
("snapshots_updated", &mut rep.snapshots_updated),
] {
if let Ok(list) = report.get::<Table>(key) {
*into = list
.sequence_values::<String>()
.collect::<mlua::Result<_>>()?;
}
}
phase("run", &mut t0);
rep.passed = report.get::<Option<usize>>("passed")?.unwrap_or(0);
rep.failed = report.get::<Option<usize>>("failed")?.unwrap_or(0);
if let Ok(f) = report.get::<Table>("failures") {
rep.failures = f.sequence_values::<String>().collect::<mlua::Result<_>>()?;
}
if let Ok(tests) = report.get::<Table>("tests") {
for tr in tests.sequence_values::<Table>() {
let tr = tr?;
rep.tests.push(TestResult {
name: tr.get::<Option<String>>("name")?.unwrap_or_default(),
ok: tr.get::<Option<bool>>("ok")?.unwrap_or(false),
ms: tr.get::<Option<f64>>("ms")?.unwrap_or(0.0),
});
}
}
}
_ => {
rep.file_level = true;
rep.passed = 1;
}
}
if opts.coverage {
rep.coverage = h.coverage_stop()?;
}
Ok(rep)
}