Issue: vitest-dev/vitest#9667
The HTML reporter and UI mode need to act after coverage HTML is generated, but there's no public hook for this. The internal onFinishedReportCoverage method is duck-typed in core to fill this gap:
Location: packages/vitest/src/node/core.ts#L1455
// notify builtin ui and html reporter after coverage html is generated
for (const reporter of this.reporters) {
if ('onFinishedReportCoverage' in reporter && typeof reporter.onFinishedReportCoverage === 'function') {
await reporter.onFinishedReportCoverage()
}
}onTestRunEnd ← summary line printed ("Test Files 2 passed...")
coverageProvider.reportCoverage() ← "% Coverage report from v8" table printed
onFinishedReportCoverage ← copy coverage HTML, reload UI iframe
Reordering is impossible: the coverage text table must print after the test summary line.
The onTestRunEnd → onFinishedReportCoverage split is a forced consequence.
Looking at the HTML reporter shape (packages/ui/node/reporter.ts):
onTestRunEnd— heavy async work: reads module graphs, reads source files, writes the full HTML report to diskonFinishedReportCoverage— copiescoverage.htmlDirinto the report directory
This is two separate writes to the report output. The reporter is split across hooks doing related work at different times. Making onFinishedReportCoverage official doesn't fix the underlying shape — it just adds an ugly second hook to the public API.
The only design that makes the new hook worth adding to the public API:
onTestRunEnd— collects and stores data in memory (module graph, sources, files); does not write anything to disk- New hook (fires after
coverageProvider.reportCoverage()) — does the single write: HTML report + coverage HTML copy in one atomic pass - The new hook fires whether or not coverage is enabled (reporters just skip the coverage copy if
coverage.enabledis false)
Benefits:
- Single write, single source of truth
onTestRunEnddemotes to data-collection only- No duck-type hack needed
- The new hook becomes the genuine "everything is truly done" terminal lifecycle point
on{Subject}{Verb}pattern withStart/Endpairs:onTestRunStart/onTestRunEnd,onTestModuleStart/onTestModuleEnd,onHookStart/onHookEndReady/Resultpairs for lower-level entities:onTestCaseReady/onTestCaseResult,onTestSuiteReady/onTestSuiteResultonCoverage— standalone, subject-only, fires with raw data before reporters runonInit,onWatcher*— process-level, not test-scoped
All test-lifecycle hooks carry the onTest* prefix. The new hook is about the overall run lifecycle, so it fits in the onTestRun* family.
onFinished — was the pre-new-API terminal hook (replaced by onTestRunEnd in #7069). Going back to it feels regressive and conflicts with the new hook naming style.
- Jest reporters:
onRunComplete— fires after everything including coverage - Karma:
onRunComplete— same - Rollup:
closeBundle— fires after bundle is written to disk ("seal it") - Node.js streams:
'finish'event — "all data flushed to underlying system"
None of these fit Vitest's onTest* prefix convention directly.
| Name | Pro | Con |
|---|---|---|
onTestRunFinished |
In-family, grammatically natural | End ≈ Finished — confusingly close to onTestRunEnd |
onRunEnd |
Short, implies full run | Orphaned from onTest* family; "run" vs "test run" ambiguous |
onReportEnd |
Signals "reporting done" — fits the Reporter context | No onReportStart pair; verb-object confusion ("am I reporting here?") |
onCoverageReported |
Accurate for what triggers it | Coverage-specific name even though it fires when coverage is disabled |
onCoverageEnd |
Pairs with onCoverage |
Same coverage-specificity problem; "end of coverage" is odd without coverage |
onFinished |
Recycles familiar name | Was explicitly replaced; ambiguous relative to onTestRunEnd |
onTestRunEnd already means "end of the test run" to users. The new hook is the actual end, including coverage HTML generation. Two valid framings:
onTestRunEnd= "test execution done, print summary" — needs a new name for "truly done"- Repurpose
onTestRunEnd— make it fire after coverage HTML (breaks current users, changes visual output order)
Option 1 is the safe path. Among those names, onTestRunFinished is the most consistent with the onTest* family and the least surprising to discover. The End/Finished near-synonym problem can be mitigated by documentation: "Called after all test results AND coverage reports have been generated. Use this hook to write final output artifacts. See onTestRunEnd if you only need test results."
Most consistent with existing API family. Clear differentiation when documented: onTestRunEnd = tests done (data available), onTestRunFinished = everything done (safe to write final artifacts).
The new docs hierarchy would be:
onTestRunStart
onTestModuleQueued / Collected / Start / End
onCoverage (raw data, before providers run)
onTestRunEnd (tests + summary — print stdout output here)
[coverage providers generate HTML/text]
onTestRunFinished (NEW — everything done, write final artifacts here)
Instead of a new hook, coverageProvider.reportCoverage() could become a built-in internal reporter whose onTestRunEnd runs last. Third-party reporters that need to run after it (HTML reporter, UI reporter) would use a { order: 'post', handler() {} } per-hook syntax — the same pattern Rollup uses for plugin hooks, which Vite/Vitest already use for Vite plugins (enforce: 'pre' | 'post').
Execution order with this approach:
onTestRunEnd (normal, in reporter-list order):
verbose/dot reporter → prints "Test Files: 2 passed"
coverage built-in reporter → prints text table + generates HTML
onTestRunEnd (post):
HTML reporter → single write: report + copy coverage HTML
UI reporter → single write: assets + iframe reload signal
Visual output order is preserved. HTML/UI reporters become single-write with no split across hooks.
Implementation shape:
// Reporter interface
interface Reporter {
onTestRunEnd?:
| ((modules, errors, reason) => Awaitable<void>)
| { order: 'post', handler: (modules, errors, reason) => Awaitable<void> }
}
// HTML reporter
class HTMLReporter implements Reporter {
onTestRunEnd = {
order: 'post' as const,
async handler() {
// collect + write + copy coverage HTML — all in one pass
}
}
}allTestsRun is not actually a friction point
allTestsRun is passed to both generateCoverage({ allTestsRun }) and reportCoverage(coverage, allTestsRun). But the coverage map returned by generateCoverage already has allTestsRun baked in (uncovered files included or not). The only independent use in reportCoverage is gating thresholds.autoUpdate. The coverage provider can simply store allTestsRun as instance state during generateCoverage and reuse it — no need to thread it through any reporter API.
Class method ergonomics problem
onTestRunEnd = { order: 'post', handler() {} } as a class property works in JS/TS but is unusual — class methods aren't normally object literals. Alternatives:
- Class-level
orderproperty (applies to all hooks, less granular) - Static metadata:
static hooks = { onTestRunEnd: { order: 'post' } } - Separate interface:
Reporter+ReporterOptionswithorder
Verdict on this alternative: stronger than it looks
A new hook like onTestRunFinished just shifts the battleground — multiple reporters would compete over it the same way they compete over the post-coverage slot now. The ordering problem recurs at every level you add a hook.
The order: 'post' mechanism is the more principled answer: it's general, composable, and doesn't require a new hook for every "I need to run after X" use case. The ergonomics concern (class method syntax) is an implementation detail, not a design flaw. This is worth pursuing as the primary approach.
- PR #9626 — introduces
coverage.htmlDirconfig (single source of truth for where coverage HTML lives) packages/vitest/src/api/setup.ts— ws-client broadcastsonFinishedReportCoverageto browser for iframe reload (internal, keep separate)