Skip to content

Instantly share code, notes, and snippets.

@hi-ogawa
Created April 8, 2026 06:47
Show Gist options
  • Select an option

  • Save hi-ogawa/18516e1cb78c7b08ee2bce470cda2cbb to your computer and use it in GitHub Desktop.

Select an option

Save hi-ogawa/18516e1cb78c7b08ee2bce470cda2cbb to your computer and use it in GitHub Desktop.
issue #9667 - reporter hook after coverage HTML generation

Issue #9667 — Reporter hook after coverage HTML generation

Issue: vitest-dev/vitest#9667

Problem

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()
  }
}

Current execution order (locked — cannot reorder)

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 onTestRunEndonFinishedReportCoverage split is a forced consequence.

Why "just make onFinishedReportCoverage official" is not enough

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 disk
  • onFinishedReportCoverage — copies coverage.htmlDir into 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 justified design

The only design that makes the new hook worth adding to the public API:

  1. onTestRunEnd — collects and stores data in memory (module graph, sources, files); does not write anything to disk
  2. New hook (fires after coverageProvider.reportCoverage()) — does the single write: HTML report + coverage HTML copy in one atomic pass
  3. The new hook fires whether or not coverage is enabled (reporters just skip the coverage copy if coverage.enabled is false)

Benefits:

  • Single write, single source of truth
  • onTestRunEnd demotes to data-collection only
  • No duck-type hack needed
  • The new hook becomes the genuine "everything is truly done" terminal lifecycle point

Naming analysis

Existing naming conventions in the Reporter interface

  • on{Subject}{Verb} pattern with Start/End pairs: onTestRunStart/onTestRunEnd, onTestModuleStart/onTestModuleEnd, onHookStart/onHookEnd
  • Ready/Result pairs for lower-level entities: onTestCaseReady/onTestCaseResult, onTestSuiteReady/onTestSuiteResult
  • onCoverage — standalone, subject-only, fires with raw data before reporters run
  • onInit, 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.

What the old API used

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.

Ecosystem references

  • 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.

Candidates and problems

Name Pro Con
onTestRunFinished In-family, grammatically natural EndFinished — 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

The core tension

onTestRunEnd already means "end of the test run" to users. The new hook is the actual end, including coverage HTML generation. Two valid framings:

  1. onTestRunEnd = "test execution done, print summary" — needs a new name for "truly done"
  2. 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."

Verdict: onTestRunFinished

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)

Alternative: coverage as built-in reporter + hook ordering

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 order property (applies to all hooks, less granular)
  • Static metadata: static hooks = { onTestRunEnd: { order: 'post' } }
  • Separate interface: Reporter + ReporterOptions with order

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.

Related

  • PR #9626 — introduces coverage.htmlDir config (single source of truth for where coverage HTML lives)
  • packages/vitest/src/api/setup.ts — ws-client broadcasts onFinishedReportCoverage to browser for iframe reload (internal, keep separate)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment