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/// What this library writes under [`crate::lib_dir`]: its declaration, and the path it
21/// takes there. A list of one, and a list rather than the constant because
22/// [`crate::lib_dir`] hashes it into the directory's name — the name and the contents come
23/// from the same place, so the first cannot describe files the binary does not write.
24pub(crate) fn declarations() -> Vec<(String, String)> {
25 vec![("htl/test.d.tl".to_string(), TEST_DTL.to_string())]
26}
27
28/// [`crate::lib_dir`] with this library's declaration in it: `htl/test.d.tl`, written on
29/// demand, only when its content changes.
30pub fn lib_dir() -> Result<PathBuf> {
31 let dir = crate::lib_dir();
32 for (path, source) in declarations() {
33 write_if_changed(&dir.join(path), &source)
34 .with_context(|| format!("writing bundled declarations under {}", dir.display()))?;
35 }
36 Ok(dir)
37}
38
39impl Htl {
40 /// Make `require("htl.test")` work at runtime and its types visible to the checker.
41 pub fn install_test_lib(&self) -> Result<()> {
42 // A label, not a path: the library ships inside the binary, so `htl/test.tl` is
43 // not a file anyone could open. Its frames read `htl.test:404` and stop there.
44 self.preload_at(DEFAULT_LIB, &format!("={DEFAULT_LIB}"), TEST_LUA)?;
45 self.add_path(&lib_dir()?)?;
46 Ok(())
47 }
48}
49
50/// Outcome of one test file.
51#[derive(Debug, Default, Clone)]
52pub struct FileReport {
53 /// The test file this is about, as it was discovered or named on the command line.
54 pub path: PathBuf,
55 /// What checking it found. A file that does not type-check still gets a report —
56 /// with this carrying the errors and no tests below it — rather than being dropped,
57 /// so a run says which files it could not get to.
58 pub check: CheckInfo,
59 /// Runtime error outside any test (e.g. the file itself raised).
60 pub error: Option<String>,
61 /// Tests the library reported as passing.
62 pub passed: usize,
63 /// Tests it reported as failing. `ok` is false while this is non-zero, and the run's
64 /// exit code is the sum of it over every file.
65 pub failed: usize,
66 /// One message per failure, already formatted by the library — the runner owns no
67 /// assertion and so has nothing of its own to say about why one failed.
68 pub failures: Vec<String>,
69 /// `true` when no test library was used and the verdict is file-level.
70 pub file_level: bool,
71 /// Per-test outcomes when the library reports them (`htl.test` does).
72 pub tests: Vec<TestResult>,
73 /// Wall time for the whole file: check, load, and every test.
74 pub duration_ms: f64,
75 /// Snapshot files created on this run (first `to_match_snapshot` of a name).
76 pub snapshots_written: Vec<String>,
77 /// Snapshot files rewritten because `update_snapshots` was set.
78 pub snapshots_updated: Vec<String>,
79 /// With `coverage`: `(chunk source as Lua names it, executed lines)`.
80 pub coverage: Vec<(String, Vec<usize>)>,
81}
82
83/// One test's outcome, as reported by the assertion library.
84#[derive(Debug, Clone, Default)]
85pub struct TestResult {
86 /// The name the library reported. `htl.test` joins its `describe` and `it` text with
87 /// ` > `, and that whole string is what `--filter` matches against.
88 pub name: String,
89 /// Whether it passed. A file's [`failures`](FileReport::failures) carry why the false
90 /// ones did; this is the per-test verdict a reporter lists.
91 pub ok: bool,
92 /// Wall time for this test alone, against
93 /// [`FileReport::duration_ms`](FileReport::duration_ms) for the file around it.
94 pub ms: f64,
95}
96
97/// Runner options passed through to the library's `run(filter, opts)`.
98#[derive(Debug, Clone, Default)]
99pub struct RunOptions {
100 /// Stop at the first failing test in a file.
101 pub fail_fast: bool,
102 /// Rewrite snapshots that differ instead of failing (`htl test --update`).
103 pub update_snapshots: bool,
104 /// Record executed lines per chunk while the file runs (`htl test --coverage`).
105 pub coverage: bool,
106 /// Seed for the run (`htl test --seed`). Each file draws from a stream derived from
107 /// this and its own path, so one file's values do not depend on which other files ran
108 /// or in what order: `--filter` reproduces what the full run did, and a failure can be
109 /// looked at again on its own. `None` leaves the state's own seeding alone.
110 pub seed: Option<u64>,
111}
112
113/// The seed one file gets, from the run's seed and its path.
114///
115/// Derived rather than taken from a shared stream, so the answer to "what did this file
116/// draw" does not depend on the company it kept. SplitMix64 over an FNV-1a of the path:
117/// the point is that neighbouring paths land far apart, not that it is unguessable.
118pub fn file_seed(run_seed: u64, path: &Path) -> u64 {
119 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
120 for b in path.to_string_lossy().as_bytes() {
121 h ^= *b as u64;
122 h = h.wrapping_mul(0x0000_0100_0000_01b3);
123 }
124 let mut z = run_seed.wrapping_add(h).wrapping_add(0x9e37_79b9_7f4a_7c15);
125 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
126 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
127 z ^ (z >> 31)
128}
129
130/// Where a test file's snapshots live: `<dir>/__snapshots__/<file stem>/`.
131pub fn snapshot_dir(test_file: &Path) -> PathBuf {
132 let stem = test_file
133 .file_stem()
134 .and_then(|s| s.to_str())
135 .unwrap_or("test");
136 parent_dir(test_file).join("__snapshots__").join(stem)
137}
138
139impl FileReport {
140 /// Whether this file is a pass: it checked, it did not raise outside a test, and no
141 /// test failed. The three are separate fields because a reporter says which of them
142 /// went wrong, and one answer is what an exit code needs.
143 pub fn ok(&self) -> bool {
144 self.check.ok() && self.error.is_none() && self.failed == 0
145 }
146}
147
148/// `*_test.tl` anywhere, plus every `.tl` under a directory named `tests`.
149/// Explicit file paths are always included. Does not enter [`crate::SKIP_DIRS`],
150/// dot-directories or the project's mlua-pkg dir (dependencies' tests are theirs).
151pub fn discover_tests(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
152 discover_tests_skipping(paths, &[])
153}
154
155/// [`discover_tests`], not entering `skip` either — directories named by path rather than
156/// by name ([`crate::patched_dirs`]: a patched dependency's `*_test.tl` are its own suite,
157/// not the project's).
158pub fn discover_tests_skipping(paths: &[PathBuf], skip: &[PathBuf]) -> Result<Vec<PathBuf>> {
159 let mut out = Vec::new();
160 for p in paths {
161 if p.is_file() {
162 out.push(p.clone());
163 continue;
164 }
165 let mut extra = crate::project_skip_dirs(p);
166 extra.extend(skip.iter().cloned());
167 let root = p.clone();
168 let walker = walkdir::WalkDir::new(p)
169 .sort_by_file_name()
170 .into_iter()
171 .filter_entry(move |e| e.path() == root || !crate::is_skipped_dir(e.path(), &extra));
172 for e in walker {
173 let e = e?;
174 let path = e.path();
175 if !crate::is_tl_source(path) {
176 continue;
177 }
178 let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
179 let in_tests_dir = path
180 .strip_prefix(p)
181 .ok()
182 .map(|rel| rel.components().any(|c| c.as_os_str() == "tests"))
183 .unwrap_or(false);
184 if name.ends_with("_test.tl") || in_tests_dir {
185 out.push(path.to_path_buf());
186 }
187 }
188 }
189 out.sort();
190 out.dedup();
191 Ok(out)
192}
193
194/// Run one test file in a fresh state. `lib` is the assertion library module to
195/// consult for `run(filter)` after the file executed (default: `htl.test`).
196pub fn run_test_file(
197 path: &Path,
198 filter: Option<&str>,
199 lib: &str,
200 lint_spec: Option<&str>,
201 opts: &RunOptions,
202) -> Result<FileReport> {
203 TestSession::new(lint_spec, lib, filter, opts.clone())?.run_file(path)
204}
205
206/// A whole project's tests, run from Rust.
207///
208/// [`run_test_file`] runs one file and [`crate::project::test`] runs a project; this is
209/// the second of those with the parts a command line supplies filled in with defaults, so
210/// that a host embedding Teal can put its scripts' tests in `cargo test` instead of
211/// shelling out to `htl test`:
212///
213/// ```rust,ignore
214/// #[test]
215/// fn teal_tests_pass() {
216/// let rep = htl::testing::run_tests(&["scripts".into()], &htl::testing::Suite::default())
217/// .expect("the run itself");
218/// assert!(rep.ok(), "{:#?}", rep.failures());
219/// }
220/// ```
221///
222/// Every field is what `htl test` takes a flag for, and [`Default`] is what the command
223/// does with no flags — except the store, which is off here: a run under `cargo test`
224/// starts wherever cargo put it, and a host that wants the cache asks for it.
225///
226/// A whole project is more than one file, so this is the project layer's
227/// ([`crate::project::test`]) and asks for its features; the umbrella `htl` crate a host
228/// depends on has both.
229#[cfg(all(feature = "pkg", feature = "dts"))]
230#[derive(Debug, Clone, Default)]
231pub struct Suite {
232 /// Run only the tests whose name contains this (`htl test --filter`).
233 pub filter: Option<String>,
234 /// Module name of the assertion library, when it is not [`DEFAULT_LIB`].
235 pub lib: Option<String>,
236 /// A lint selection, merged after `htl.toml`'s so that it wins (`htl test --lint`).
237 pub lint: Option<String>,
238 /// Fail-fast, snapshot updating, coverage and the seed. `seed: None` draws one for the
239 /// run and [`SuiteReport::seed`] says which, so a failure can be repeated.
240 pub run: RunOptions,
241 /// Keep a run cache under the project root, as `htl test` does. Off by default.
242 pub cache: bool,
243}
244
245/// What a [`run_tests`] run found: every file's report, and the counts over them.
246#[cfg(all(feature = "pkg", feature = "dts"))]
247#[derive(Debug)]
248pub struct SuiteReport {
249 /// One per file that ran, in the order they ran.
250 pub files: Vec<FileReport>,
251 /// Every diagnostic the checks produced, over the whole run, as values.
252 pub diagnostics: Vec<crate::Diagnostic>,
253 /// Tests that passed, summed over the files that ran — tests, not files, so a run of
254 /// one file with forty assertions is forty here and one in [`files`](Self::files).
255 pub passed: usize,
256 /// Tests that failed, summed the same way. A file that failed to check contributes
257 /// nothing to either count and shows up in
258 /// [`files_with_errors`](Self::files_with_errors) instead, which is why that is the
259 /// field [`ok`](Self::ok) reads.
260 pub failed: usize,
261 /// Files that failed to check, raised, or had a failing test.
262 pub files_with_errors: usize,
263 /// Files discovered but never run, because `fail_fast` stopped the run.
264 pub skipped: usize,
265 /// The seed every file's stream was derived from.
266 pub seed: u64,
267 /// Wall time for the run: discovery, every file's
268 /// [`duration_ms`](FileReport::duration_ms), and the coverage report when one was
269 /// asked for. Larger than the files' sum rather than equal to it.
270 pub duration_ms: f64,
271 /// With `run.coverage`: what the line hooks saw.
272 pub coverage: Option<crate::project::CoverageReport>,
273}
274
275#[cfg(all(feature = "pkg", feature = "dts"))]
276impl SuiteReport {
277 /// Whether every file that ran was ok. What an assertion in a `#[test]` reads.
278 pub fn ok(&self) -> bool {
279 self.files_with_errors == 0
280 }
281
282 /// What to put in the assertion message: each failing file with what went wrong —
283 /// a check that failed, a file that raised, or the tests that did not pass.
284 pub fn failures(&self) -> Vec<String> {
285 let mut out = Vec::new();
286 for f in self.files.iter().filter(|f| !f.ok()) {
287 let at = f.path.display();
288 if !f.check.ok() {
289 out.extend(f.check.errors.iter().map(|e| format!("{at}: {e}")));
290 }
291 if let Some(e) = &f.error {
292 out.push(format!("{at}: {e}"));
293 }
294 out.extend(f.failures.iter().map(|m| format!("{at}: {m}")));
295 }
296 out
297 }
298}
299
300/// Run the tests under `paths` — a project root, a directory, or the files themselves —
301/// and hand back what happened.
302///
303/// Discovery is [`discover_tests_skipping`]'s: `*_test.tl` anywhere and every `.tl` under a
304/// `tests` directory, minus a patched dependency's own suite. `htl.toml` is found from the
305/// first path, as the command does. Nothing is printed and nothing decides an exit code —
306/// [`SuiteReport`] is the whole answer, and asserting on it is the caller's.
307#[cfg(all(feature = "pkg", feature = "dts"))]
308pub fn run_tests(paths: &[PathBuf], suite: &Suite) -> Result<SuiteReport> {
309 use crate::project;
310 let paths: Vec<PathBuf> = if paths.is_empty() {
311 vec![PathBuf::from(".")]
312 } else {
313 paths.to_vec()
314 };
315 let files = discover_tests_skipping(&paths, &project::patched(&paths))?;
316 let cfg = project::config_of(&paths[0])?;
317 let opts = project::TestOptions {
318 config: &cfg,
319 lint: suite.lint.as_deref(),
320 lib: suite.lib.as_deref().unwrap_or(DEFAULT_LIB),
321 filter: suite.filter.as_deref(),
322 run: suite.run.clone(),
323 cache: project::cache_options(
324 suite.cache,
325 Some(crate::cache::Mode::PerModule),
326 &cfg,
327 false,
328 ),
329 };
330 let mut sink = project::Sink::new(project::Collect::default());
331 let mut reports: Vec<FileReport> = Vec::new();
332 let mut diagnostics: Vec<crate::Diagnostic> = Vec::new();
333 let rep = project::test(&mut sink, &files, &opts, &mut |r, sink| {
334 diagnostics.extend(sink.out().take());
335 reports.push(r.clone());
336 })?;
337 Ok(SuiteReport {
338 files: reports,
339 diagnostics,
340 passed: rep.passed,
341 failed: rep.failed,
342 files_with_errors: rep.files_with_errors,
343 skipped: rep.skipped(),
344 seed: rep.seed,
345 duration_ms: rep.duration_ms,
346 coverage: rep.coverage,
347 })
348}
349
350/// One checker for a whole run: every test file gets its own fresh program state
351/// (globals, `package.loaded`, module state), but modules are type-checked and
352/// generated once and served to every file from the checker's store.
353pub struct TestSession {
354 checker: Htl,
355 lib: String,
356 filter: Option<String>,
357 opts: RunOptions,
358}
359
360impl TestSession {
361 /// Build the one checker a run shares. `lint_spec` is the same `--lint` string the
362 /// CLI takes and is applied once here; `lib` is the module a test file requires for
363 /// its assertions ([`DEFAULT_LIB`] unless the caller has its own); `filter` and
364 /// `opts` are handed to that library's `run` for every file.
365 pub fn new(
366 lint_spec: Option<&str>,
367 lib: &str,
368 filter: Option<&str>,
369 opts: RunOptions,
370 ) -> Result<Self> {
371 let checker = Htl::new()?;
372 if let Some(spec) = lint_spec {
373 checker.configure_lints(spec)?;
374 }
375 Ok(Self {
376 checker,
377 lib: lib.to_string(),
378 filter: filter.map(String::from),
379 opts,
380 })
381 }
382
383 /// The session's checker (for [`Htl::executable_ranges`] on the sources a run touched).
384 pub fn checker(&self) -> &Htl {
385 &self.checker
386 }
387
388 /// Run one file in a fresh program state borrowing the session's checker. The
389 /// checker's search path is restored afterwards so files do not see each other's
390 /// directories.
391 pub fn run_file(&self, path: &Path) -> Result<FileReport> {
392 self.run_file_with(path, None, &[]).map(|(rep, _)| rep)
393 }
394
395 /// Run one file, reusing Lua the caller generated earlier.
396 ///
397 /// `generated` is `(the Lua, what checking it reported)`. Everything before the codegen
398 /// still happens — the searcher, the search path, the project and the config all have to
399 /// be in place before the code can execute — and everything after it happens as usual.
400 /// Only the check and the codegen are skipped.
401 ///
402 /// The run itself is never reused, and this signature cannot express reusing it: a test
403 /// has to run to say whether it passes.
404 ///
405 /// Returns the report and, when this call generated the Lua rather than being handed it,
406 /// that Lua — so a caller keeping a cache has something to keep.
407 /// `preload` is `(module name, its generated Lua, the file it came from)` for modules
408 /// this file will require. Each one goes in front of the searcher, so requiring it does
409 /// not check and generate it during the run. A module not in the list still loads the
410 /// usual way; the list is an optimisation, never a restriction on what can be required.
411 pub fn run_file_with(
412 &self,
413 path: &Path,
414 generated: Option<(&str, &CheckInfo)>,
415 preload: &[(String, String, PathBuf)],
416 ) -> Result<(FileReport, Option<String>)> {
417 let started = std::time::Instant::now();
418 let saved = self.checker.search_path()?;
419 let h = Htl::with_checker(&self.checker)?;
420 let mut code = None;
421 let out = run_in(
422 &h,
423 path,
424 RunIn {
425 filter: self.filter.as_deref(),
426 lib: &self.lib,
427 opts: &self.opts,
428 generated,
429 preload,
430 },
431 &mut code,
432 );
433 self.checker.set_search_path(&saved)?;
434 let mut rep = out?;
435 rep.duration_ms = started.elapsed().as_secs_f64() * 1000.0;
436 Ok((rep, code))
437 }
438}
439
440/// What one file's run needs from its session, and what the caller already has for it.
441struct RunIn<'a> {
442 filter: Option<&'a str>,
443 lib: &'a str,
444 opts: &'a RunOptions,
445 /// Lua and diagnostics from an earlier run, when the caller kept them.
446 generated: Option<(&'a str, &'a CheckInfo)>,
447 /// Modules to put in front of the searcher before the file executes.
448 preload: &'a [(String, String, PathBuf)],
449}
450
451fn run_in(h: &Htl, path: &Path, r: RunIn<'_>, out_code: &mut Option<String>) -> Result<FileReport> {
452 let RunIn {
453 filter,
454 lib,
455 opts,
456 generated,
457 preload,
458 } = r;
459 let mut rep = FileReport {
460 path: path.to_path_buf(),
461 ..Default::default()
462 };
463 let profile = std::env::var_os("HTL_PROFILE").is_some();
464 let mut t0 = std::time::Instant::now();
465 let phase = |label: &str, t0: &mut std::time::Instant| {
466 if profile {
467 eprintln!(
468 "profile: {label:<8} {:7.1} ms {}",
469 t0.elapsed().as_secs_f64() * 1000.0,
470 path.display()
471 );
472 }
473 *t0 = std::time::Instant::now();
474 };
475 phase("state", &mut t0);
476 h.install_test_lib()?;
477 #[cfg(feature = "std")]
478 h.install_std()?;
479 let dir = parent_dir(path);
480 h.add_path(&dir)?;
481 #[cfg(feature = "pkg")]
482 if let Some(p) = crate::pkg::Project::find(path) {
483 h.apply_project(&p)?;
484 }
485 if let Some((cfg_path, cfg)) = crate::config::HtlConfig::find(path)? {
486 h.apply_config(&parent_dir(&cfg_path), &cfg)?;
487 }
488 // tests/foo_test.tl commonly requires modules from the project root or src/.
489 if dir.file_name().is_some_and(|n| n == "tests")
490 && let Some(root) = dir.parent()
491 {
492 h.add_path(root)?;
493 h.add_path(&root.join("src"))?;
494 }
495 h.install_searcher()?;
496 // Before the searcher gets a chance to be asked. Position 1 beats position 2.
497 for (name, code, from) in preload {
498 h.preload_generated(name, code, from)?;
499 }
500 h.set_arg(&path.to_string_lossy(), &[])?;
501 phase("setup", &mut t0);
502
503 let (code, check) = match generated {
504 Some((code, check)) => (Some(code.to_string()), check.clone()),
505 None => {
506 let (code, check) = h.gen_lua(path)?;
507 // Hand the caller what was generated, before any of the early returns below: a
508 // file whose tests fail still generated the Lua that failed, and the next run
509 // should not have to generate it again to find that out.
510 *out_code = code.clone();
511 (code, check)
512 }
513 };
514 phase("gen_lua", &mut t0);
515 rep.check = check;
516 let Some(code) = code else { return Ok(rep) };
517 // Before the file runs, so a module it requires draws from the same stream. Seeding
518 // the state's own generator rather than handing out a private one means a test that
519 // already calls `math.random` becomes reproducible without being rewritten.
520 if let Some(run_seed) = opts.seed {
521 let math: Table = h.lua().globals().get("math")?;
522 let randomseed: Function = math.get("randomseed")?;
523 randomseed.call::<()>(file_seed(run_seed, path) as i64)?;
524 }
525 if opts.coverage {
526 h.coverage_start()?;
527 }
528 if let Err(e) = h.exec(&code, &format!("@{}", path.display()), &[]) {
529 // With the frames: a file that raised while loading is a development failure, and
530 // the per-test failures beside it have carried a traceback all along.
531 rep.error = Some(crate::developer_message(&e));
532 if opts.coverage {
533 rep.coverage = h.coverage_stop()?;
534 }
535 return Ok(rep);
536 }
537 phase("exec", &mut t0);
538
539 // Did the file load the assertion library? Then ask it for the verdict.
540 let package: Table = h.lua().globals().get("package")?;
541 let loaded: Table = package.get("loaded")?;
542 match loaded.get::<Value>(lib)? {
543 Value::Table(t) => {
544 // Snapshots: tell the library where this file's live and whether to
545 // rewrite them. Lua cannot create a directory, so it borrows one.
546 if let Ok(configure) = t.get::<Function>("configure") {
547 let cfg = h.lua().create_table()?;
548 cfg.set(
549 "snapshot_dir",
550 snapshot_dir(path).to_string_lossy().as_ref(),
551 )?;
552 cfg.set("update", opts.update_snapshots)?;
553 cfg.set(
554 "mkdir",
555 h.lua().create_function(|_, dir: String| {
556 std::fs::create_dir_all(&dir).map_err(mlua::Error::external)
557 })?,
558 )?;
559 configure.call::<()>(cfg)?;
560 }
561 let run: Function = t.get("run")?;
562 let lua_opts = h.lua().create_table()?;
563 lua_opts.set("fail_fast", opts.fail_fast)?;
564 let report: Table = run.call((filter, lua_opts))?;
565 for (key, into) in [
566 ("snapshots_written", &mut rep.snapshots_written),
567 ("snapshots_updated", &mut rep.snapshots_updated),
568 ] {
569 if let Ok(list) = report.get::<Table>(key) {
570 *into = list
571 .sequence_values::<String>()
572 .collect::<mlua::Result<_>>()?;
573 }
574 }
575 phase("run", &mut t0);
576 rep.passed = report.get::<Option<usize>>("passed")?.unwrap_or(0);
577 rep.failed = report.get::<Option<usize>>("failed")?.unwrap_or(0);
578 if let Ok(f) = report.get::<Table>("failures") {
579 rep.failures = f.sequence_values::<String>().collect::<mlua::Result<_>>()?;
580 }
581 if let Ok(tests) = report.get::<Table>("tests") {
582 for tr in tests.sequence_values::<Table>() {
583 let tr = tr?;
584 rep.tests.push(TestResult {
585 name: tr.get::<Option<String>>("name")?.unwrap_or_default(),
586 ok: tr.get::<Option<bool>>("ok")?.unwrap_or(false),
587 ms: tr.get::<Option<f64>>("ms")?.unwrap_or(0.0),
588 });
589 }
590 }
591 }
592 _ => {
593 rep.file_level = true;
594 rep.passed = 1;
595 }
596 }
597 if opts.coverage {
598 rep.coverage = h.coverage_stop()?;
599 }
600 Ok(rep)
601}