Skip to content

Per-test server-side coverage for RSC apps on a single shared server #13

Description

@Romex91

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:422captureWindowCoverage() reads window.__coverage__${resultsFolder}/coverage.json
  • packages/shaka-perf/src/audit/stages/audit/engine.ts:197mirrorCoverageToNycOutput() copies each unit's coverage.json into .nyc_output/${slug}.json
  • packages/shaka-perf/src/audit/stages/audit/program.tsmaybeGenerateCoverageReport() 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.

  1. 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.
  2. 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.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions