Context
The audit pipeline captures code coverage today by reading the browser's
window.__coverage__ (populated by istanbul-instrumented client bundles) after
the user's testFn resolves, then aggregating per-(test, viewport) files into
one nyc/Istanbul report:
packages/shaka-perf/src/bench/core/lighthouse-worker.ts:422 — captureWindowCoverage() reads window.__coverage__ → ${resultsFolder}/coverage.json
packages/shaka-perf/src/audit/stages/audit/engine.ts:197 — mirrorCoverageToNycOutput() copies each unit's coverage.json into .nyc_output/${slug}.json
packages/shaka-perf/src/audit/stages/audit/program.ts — maybeGenerateCoverageReport() runs nyc report to merge everything into one HTML report
This is browser-side only. There is no server-side coverage anywhere in the
repo (no NODE_V8_COVERAGE, c8, or server-bundle istanbul).
Problem
When a project under test uses React Server Components, server components
execute only on the server and never ship executable JS to the browser — they
emit a serialized Flight payload. So they never appear in
window.__coverage__ and show as 0%/absent even though they ran on every
request. Only "use client" components are visible.
We want per-test server coverage reports (per test, not per viewport — the
viewports of one test exercise nearly the same code and can be merged), from a
single shared server (one server process is the whole point; spinning a
server per test/worker is the cost we're trying to avoid).
The hard constraint: V8 native coverage (NODE_V8_COVERAGE / CDP
Profiler.takePreciseCoverage) is per-isolate. On one shared server handling
concurrent requests, the coverage delta mixes all in-flight requests — there is
no API to attribute a V8 counter to a specific request/session. That is exactly
why the existing RSC coverage tools force isolation.
Why existing tooling doesn't solve it
Verified across npm/GitHub — every ingredient ships, but nobody has published
the piece that joins them:
nextcov / monocart-coverage-reports — the de-facto RSC coverage tools.
Both use V8 via CDP, which is per-isolate, so they merge everything from the
process or rely on per-test serialization. They do not demux a shared
server's concurrent requests per test.
babel-plugin-istanbul — process-global. Its generated cov_*() function
rebinds itself after the first call to a cached per-file object
(confirmed in istanbul-lib-instrument/src/visitor.js coverageTemplate), so
swapping the global __coverage__ for a per-request Proxy only routes the
first request to touch each file. It can't be configured into per-request
mode.
- ALS request-context libs (
fastify-request-context,
express-async-context, OpenTelemetry's context manager) — mature per-request
scoping, but none is wired to coverage counters.
So this is something we build by composing proven parts, not new primitives.
Proposed solution: AsyncLocalStorage-scoped coverage counters
An RSC render for one request is a single async chain —
renderToReadableStream(<App/>) plus every awaited Server Component and Server
Action runs inside it — and AsyncLocalStorage propagates across every await
in that chain (~7% overhead, irrelevant for a coverage build). That is precisely
the scope we want.
- Request middleware — wrap each request in
als.run({ id, cov: {} }, next).
The test supplies id via a header so the emitted report is named per test.
- Custom instrument template (a small fork of istanbul's template, or a thin
Babel plugin) — drop the rebinding block and resolve the coverage map from ALS
on every hit: als.getStore().cov[path].s[i]++, instead of caching one
global object. The .s/.f/.b structures and report generation
(istanbul-lib-coverage + istanbul-lib-report) stay stock.
- On request finish — serialize
store.cov → one Istanbul JSON → one report.
Result: N tests, N reports, from one server, one isolate, fully parallel,
attributed exactly to the code each test executed. Only instrumentation (not V8)
can do this, because the counter is our code.
Alternative if forking the instrumenter is undesirable
One server process, but dispatch each request's render to a worker_thread
(each has its own V8 isolate) and collect that worker's coverage via an in-thread
inspector Profiler session. Native V8, no Babel fork — at the cost of
duplicating the module graph per worker and routing RSC rendering through
workers (not a framework default).
Caveat (property of the app, not the mechanism)
Per-request instrumentation reports exactly what each request executed, so
lazily-initialized module singletons get attributed to whichever test triggered
them first and look uncovered for the rest. React's per-request cache() is
fine; app-level lazy init / connection pools / memoized module state are
first-touch. Decide whether to warm the server before the run (init excluded from
all reports) or count it against test shakacode/shakaperf-old#1.
References
Context
The audit pipeline captures code coverage today by reading the browser's
window.__coverage__(populated by istanbul-instrumented client bundles) afterthe user's
testFnresolves, then aggregating per-(test, viewport)files intoone nyc/Istanbul report:
packages/shaka-perf/src/bench/core/lighthouse-worker.ts:422—captureWindowCoverage()readswindow.__coverage__→${resultsFolder}/coverage.jsonpackages/shaka-perf/src/audit/stages/audit/engine.ts:197—mirrorCoverageToNycOutput()copies each unit'scoverage.jsoninto.nyc_output/${slug}.jsonpackages/shaka-perf/src/audit/stages/audit/program.ts—maybeGenerateCoverageReport()runsnyc reportto merge everything into one HTML reportThis is browser-side only. There is no server-side coverage anywhere in the
repo (no
NODE_V8_COVERAGE, c8, or server-bundle istanbul).Problem
When a project under test uses React Server Components, server components
execute only on the server and never ship executable JS to the browser — they
emit a serialized Flight payload. So they never appear in
window.__coverage__and show as 0%/absent even though they ran on everyrequest. Only
"use client"components are visible.We want per-test server coverage reports (per test, not per viewport — the
viewports of one test exercise nearly the same code and can be merged), from a
single shared server (one server process is the whole point; spinning a
server per test/worker is the cost we're trying to avoid).
The hard constraint: V8 native coverage (
NODE_V8_COVERAGE/ CDPProfiler.takePreciseCoverage) is per-isolate. On one shared server handlingconcurrent requests, the coverage delta mixes all in-flight requests — there is
no API to attribute a V8 counter to a specific request/session. That is exactly
why the existing RSC coverage tools force isolation.
Why existing tooling doesn't solve it
Verified across npm/GitHub — every ingredient ships, but nobody has published
the piece that joins them:
nextcov/monocart-coverage-reports— the de-facto RSC coverage tools.Both use V8 via CDP, which is per-isolate, so they merge everything from the
process or rely on per-test serialization. They do not demux a shared
server's concurrent requests per test.
babel-plugin-istanbul— process-global. Its generatedcov_*()functionrebinds itself after the first call to a cached per-file object
(confirmed in
istanbul-lib-instrument/src/visitor.jscoverageTemplate), soswapping the global
__coverage__for a per-request Proxy only routes thefirst request to touch each file. It can't be configured into per-request
mode.
fastify-request-context,express-async-context, OpenTelemetry's context manager) — mature per-requestscoping, but none is wired to coverage counters.
So this is something we build by composing proven parts, not new primitives.
Proposed solution: AsyncLocalStorage-scoped coverage counters
An RSC render for one request is a single async chain —
renderToReadableStream(<App/>)plus every awaited Server Component and ServerAction runs inside it — and
AsyncLocalStoragepropagates across everyawaitin that chain (~7% overhead, irrelevant for a coverage build). That is precisely
the scope we want.
als.run({ id, cov: {} }, next).The test supplies
idvia a header so the emitted report is named per test.Babel plugin) — drop the rebinding block and resolve the coverage map from ALS
on every hit:
als.getStore().cov[path].s[i]++, instead of caching oneglobal object. The
.s/.f/.bstructures and report generation(
istanbul-lib-coverage+istanbul-lib-report) stay stock.store.cov→ one Istanbul JSON → one report.Result: N tests, N reports, from one server, one isolate, fully parallel,
attributed exactly to the code each test executed. Only instrumentation (not V8)
can do this, because the counter is our code.
Alternative if forking the instrumenter is undesirable
One server process, but dispatch each request's render to a
worker_thread(each has its own V8 isolate) and collect that worker's coverage via an in-thread
inspectorProfiler session. Native V8, no Babel fork — at the cost ofduplicating the module graph per worker and routing RSC rendering through
workers (not a framework default).
Caveat (property of the app, not the mechanism)
Per-request instrumentation reports exactly what each request executed, so
lazily-initialized module singletons get attributed to whichever test triggered
them first and look uncovered for the rest. React's per-request
cache()isfine; app-level lazy init / connection pools / memoized module state are
first-touch. Decide whether to warm the server before the run (init excluded from
all reports) or count it against test shakacode/shakaperf-old#1.
References