Skip to content

Instantly share code, notes, and snippets.

@hi-ogawa
Created March 31, 2026 01:19
Show Gist options
  • Select an option

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

Select an option

Save hi-ogawa/a12115ae69358e9a68ad7f9ede0923fd to your computer and use it in GitHub Desktop.
vitest#6425 self-contained HTML reporter investigation & plan

Self-contained HTML reporter

Plan for implementing a single-file output mode for @vitest/ui's HTML reporter. Tracking issue: vitest#6425

Goal

Add an option (e.g. reporter: [['html', { singleFile: true }]]) that produces a single index.html with no external dependencies.

1. Inline direct assets

What: index.html references a JS bundle and CSS bundle (plus favicon, Google Fonts).

Where:

Plan: In writeReport(), read index.html, read each asset file, replace <link> / <script src> tags with inline <style> / <script> blocks. Google Fonts link can be dropped.

2. Inline html.meta.json.gz — straightforward

What: The reporter writes html.meta.json.gz and injects window.METADATA_PATH into index.html. The client fetches it at runtime.

Where:

Plan: Instead of writing the gz file and setting METADATA_PATH to a file path, embed the base64 gz directly as window.METADATA_DATA. Client-side: if window.METADATA_DATA is set, skip the fetch and decode directly. Minor wiring on both ends; the gzip/flatted pipeline is unchanged.

3. Inline attachments — trivial

What: Attachments with attachment.path are copied to ./data/ and referenced as ./data/<basename>. Attachments with attachment.body are already base64 data URIs.

Where:

Plan: In self-contained mode, for each attachment.path, read the file and convert to base64, then store as attachment.body. Skip the ./data/ copy step entirely. No client-side changes needed — attachment.body path already renders as data URIs.

4. Inline coverage — open question

What: Coverage HTML is a full multi-file report (generated by v8/istanbul's HTML reporter) copied to ./coverage/. The UI mounts it in an iframe at ./coverage/index.html.

Where:

Problem: Coverage is itself a multi-file HTML site (many JS/CSS/per-file HTML pages). Inlining it fully is non-trivial and coverage reporters are third-party.

Deferred as follow-up. Coverage is an MPA (many per-file HTML pages) — a general problem tracked separately in 6425-mpa-to-single-html.md. Once that general MPA→single-HTML tool exists, coverage inlining wires in as a consumer.

Implementation sketch

reporter.ts writeReport() in self-contained mode:
  1. Read dist/client/index.html
  2. Read + inline JS/CSS assets → replace <script src> / <link> tags
  3. Serialize metadata → base64 gz → inject as window.METADATA_DATA inline <script>
  4. Convert attachment.path entries → attachment.body (base64) in metadata
  5. Skip copying ./assets/, ./data/, ./coverage/
  6. Write single index.html

static.ts registerMetadata():
  + if (window.METADATA_DATA) decode directly, skip fetch

Single-file HTML reporter via SW + embedded zip

Investigation for vitest#6425 and hi-ogawa's comment.

Problem

The HTML reporter outputs a directory (html/). The requester's CI pipeline requires single-file artifacts and cannot process multi-file output at all.

S3 upload (where directory structure flattens) is mentioned only as an already-failed alternative they tried — not the root cause.

Proposed approach: SW + embedded zip

Instead of inlining every asset into HTML (complex, touches reporter logic), embed the whole report as a zip inside a single HTML file. On load:

  1. HTML decodes the embedded zip (base64 or typed array literal)
  2. Unpacks via fflate, stores files into Cache API
  3. Registers a service worker that intercepts fetches and serves from Cache
  4. Loads the report in an iframe (or navigates)

Reporter change is minimal: just zip what it already generates and wrap with a loader shell. This is the same technique as zipview.

Key concern: SW scope under subpath

Your comment flags this. The issue:

  • SW scope is inferred from the path of sw.js (or set explicitly via Service-Worker-Allowed header)
  • Cache keys in zipview are hardcoded to /zipview/site/... (absolute paths)
  • If report is served at my-domain.com/reports/2025-03-30/report.html, the SW registers under /reports/2025-03-30/ but tries to intercept /zipview/site/* → mismatch

Solutions

A. Relative cache paths (preferred, no server config needed)

Compute PREFIX dynamically from location.href at runtime:

// In both index HTML and SW
const base = new URL('.', location.href).pathname;  // "/reports/2025-03-30/"
const PREFIX = base + "zipview-site/";              // "/reports/2025-03-30/zipview-site/"

Both the caching logic and the SW intercept pattern use the same dynamic PREFIX. The SW receives the base path via clients.claim() + a postMessage or via a URL param when registered: register('./sw.js?base=' + encodeURIComponent(base)), then the SW reads new URL(location).searchParams.get('base') from self.location.

B. Service-Worker-Allowed: / header

Server sends this header on sw.js. Allows SW registered at any subpath to claim /. Cache keys stay absolute. Simple but requires server-side configuration.

C. Not applicable here: blob URL SW

Can't register a SW via blob URL with useful scope — scope is locked to blob: origin. So true single-file (zero separate files) via SW is not possible this way.

Single-file constraint

The issue title and body say "single self-contained HTML file" three times explicitly. S3 flattening is the symptom, but single file is the hard requirement.

SW approach is therefore a dead end: SW must be a fetchable URL — it cannot be inlined (blob URL SWs have no useful scope). Two files = doesn't satisfy the request.

Remaining options for true single-file:

Approach Notes
Full asset inlining What they literally asked for. Embed JS/CSS as <script>/<style>, fonts/images as base64. Complex but straightforward.
Embedded zip + object URLs Unzip on load, create URL.createObjectURL() for each asset, rewrite all asset references in HTML/JS/CSS to blob URLs. Very tricky with dynamic imports and relative paths inside the report.
Embedded zip + SW (blob URL) Blocked — blob URL SWs can't claim useful scope.

Next steps

  • Clarify with issue reporter: is "single file" a hard requirement, or would two co-located files (report.html + report.sw.js) solve their S3 problem?
  • Prototype: zipview's index.html + sw.js as the shell, reporter zips its output and the build step embeds it — measure output size for a typical report
  • Fix subpath issue in zipview itself first (relative cache paths) as a prerequisite
  • Decide: implement in @vitest/ui package or as a separate @vitest/reporter-html-zip?

MPA → single index.html

General problem statement, independent of vitest HTML reporter.

Problem

Given a static MPA (directory of HTML pages + assets), produce a single self-contained index.html with zero external dependencies that faithfully reproduces the MPA in-browser.

Analogous to how zipview solved "view a static site from a zip without local setup" — but the output artifact is a single HTML file instead of requiring a separate viewer.

Prior art

  • zipview: zip → browser, needs external viewer, SW handles navigation
  • Playwright trace viewer: ships a self-contained trace.zip that opens via a hosted viewer. Not single-file.
  • monolith CLI: saves a single web page as self-contained HTML, but only one page — doesn't handle MPA navigation.

Core challenges

  1. Asset inlining — CSS, JS, images, fonts → inline <style>, <script>, data URIs
  2. Page storage — where to keep all N HTML pages inside the single file
  3. Navigation — intercept link clicks, handle relative href resolution, update view
  4. Isolation — each page's scripts/styles must not leak into each other or the shell

Architecture

Page storage

Store all pages as a JS map in the shell:

window.__PAGES__ = {
  'index.html': '<!DOCTYPE html>...',
  'src/foo.ts.html': '<!DOCTYPE html>...',
}

Isolation + navigation: srcdoc iframe + postMessage

An iframe with srcdoc gives per-page JS/CSS isolation naturally.

Navigation problem: clicks inside srcdoc try to load real URLs that don't exist. Solution: inject a small navigation shim into every page before embedding:

// injected into every page
document.addEventListener('click', e => {
  const a = e.target.closest('a[href]')
  if (!a) return
  const href = a.getAttribute('href')
  if (!href || href.startsWith('http') || href.startsWith('#')) return
  e.preventDefault()
  parent.postMessage({ __navigate__: href }, '*')
}, true)

Shell listens, resolves href relative to current page path, looks up in __PAGES__, swaps iframe.srcdoc.

Shell responsibilities

shell:
  - store __PAGES__ map
  - render iframe, set srcdoc = __PAGES__['index.html'] on load
  - listen for { __navigate__ } postMessage
  - resolve relative href: new URL(href, currentPath).pathname.slice(1)
  - swap srcdoc from map (or 404 page)
  - optionally manage history: history.pushState / popstate

What the build tool does

Input: a static MPA directory.

for each html file:
  1. inline <link rel="stylesheet"> → <style>
  2. inline <script src> → <script>
  3. inline <img src>, url() → data URIs
  4. inject navigation shim script
  5. store as entry in __PAGES__ map

output: shell.html with __PAGES__ map + iframe shell embedded

Open questions

  • Back/forward navigation: history.pushState in shell keeps URL bar in sync. popstate swaps srcdoc back. Probably good enough for report viewing.
  • Anchor links within a page (href="#section"): should be left alone (no postMessage). Already handled by the startsWith('#') check above.
  • Forms / redirects: out of scope for report viewing use case.
  • Page size: large MPAs (e.g. many coverage per-file pages) inflate the single HTML significantly. Acceptable tradeoff vs. zero-dependency requirement.
  • Shared assets across pages: inline into each page independently (simple) or deduplicate via a <template> in shell and clone (optimization, probably not needed initially).

Relation to vitest

The vitest self-contained HTML reporter would use this as a sub-step for coverage:

  • Run the general MPA→single-HTML tool on coverage/
  • Embed the resulting shell HTML as srcdoc of the coverage iframe (or directly as a nested page in the outer __PAGES__ map)

Keeps vitest-specific code clean — just wires the general tool.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment