Background
Browser mode currently finalizes a run outside the unified node run() on two code paths in packages/core/src/core/runTests.ts:
- The browser-only fast path (
hasBrowserProjects && !hasNodeProjects) — generates coverage inline, separate from run().
- The mixed browser + node early-return path (
!hasNodeTestsToRun) — when a node project is configured but resolves to zero test files, control returns before run() is ever invoked.
Coverage report generation (generateCoverage = write reports + enforce thresholds) lives only inside run(). So whenever the browser self-finalizes without run(), that responsibility is orphaned and has to be re-homed with a browser-specific "generate coverage outside run()" branch.
This is the underlying reason a generateBrowserOnlyCoverage-style helper keeps wanting to exist. It is a symptom, not the root: browser mode is not yet isomorphic with the node pools (forks / threads), so it cannot simply flow through the same run() finalize path.
Deferred gap from #1363 / PR #1386
PR #1386 fixes the hang for a mixed browser + node config whose node project matches zero test files (the browser teardown was deferred to a run() that never happened).
It intentionally does not fix coverage for that same path. In that configuration, the browser project's coverage report is currently not generated (the empty node sibling suppresses it). Fixing it today would require adding another out-of-run() coverage call, which entrenches the anti-pattern above. We chose to defer it to the isomorphism work instead.
Repro shape (mixed config, coverage enabled, node project matches no files):
// rstest.config.mts
export default defineConfig({
coverage: { enabled: true, include: ['src/**/*.ts'] },
projects: [
{ name: 'browser', browser: { enabled: true, provider: 'playwright', headless: true }, include: ['tests/sum.test.ts'] },
{ name: 'node', include: ['tests/node-empty/**/*.test.ts'] }, // matches 0 files
],
});
Expected: a coverage report is written for the browser project. Actual: no report (no run() → no generateCoverage).
Related inconsistencies the unification should also reconcile
- Failure guard divergence (verify): the browser-only coverage branch guards only on
!browserResult.unhandledErrors?.length, whereas run() guards on !isFailure || coverage.reportOnFailure. The browser path appears not to honor reportOnFailure or test-level failures — worth confirming and aligning.
generateCoverage project scope: untested-file instrumentation iterates context.projects (all configured), but the swc transform is registered per-environment only when that environment is built (coverage-istanbul/src/plugin.ts, keyed by environment.name). Iterating a configured-but-unbuilt project throws swc transform function for <env> is not registered. The correct contract is "iterate only the projects built this round," which is latent even for pure-node multi-project configs with an empty sibling.
Desired end state
Make browser mode a first-class pool type (like forks / threads) that flows through the unified run(). Then coverage generation, reporter finalize, and teardown have a single home, the out-of-run() browser coverage branch disappears, and the deferred #1363 coverage gap is resolved for free.
Links
Background
Browser mode currently finalizes a run outside the unified node
run()on two code paths inpackages/core/src/core/runTests.ts:hasBrowserProjects && !hasNodeProjects) — generates coverage inline, separate fromrun().!hasNodeTestsToRun) — when a node project is configured but resolves to zero test files, control returns beforerun()is ever invoked.Coverage report generation (
generateCoverage= write reports + enforce thresholds) lives only insiderun(). So whenever the browser self-finalizes withoutrun(), that responsibility is orphaned and has to be re-homed with a browser-specific "generate coverage outsiderun()" branch.This is the underlying reason a
generateBrowserOnlyCoverage-style helper keeps wanting to exist. It is a symptom, not the root: browser mode is not yet isomorphic with the node pools (forks/threads), so it cannot simply flow through the samerun()finalize path.Deferred gap from #1363 / PR #1386
PR #1386 fixes the hang for a mixed browser + node config whose node project matches zero test files (the browser teardown was deferred to a
run()that never happened).It intentionally does not fix coverage for that same path. In that configuration, the browser project's coverage report is currently not generated (the empty node sibling suppresses it). Fixing it today would require adding another out-of-
run()coverage call, which entrenches the anti-pattern above. We chose to defer it to the isomorphism work instead.Repro shape (mixed config, coverage enabled, node project matches no files):
Expected: a coverage report is written for the browser project. Actual: no report (no
run()→ nogenerateCoverage).Related inconsistencies the unification should also reconcile
!browserResult.unhandledErrors?.length, whereasrun()guards on!isFailure || coverage.reportOnFailure. The browser path appears not to honorreportOnFailureor test-level failures — worth confirming and aligning.generateCoverageproject scope: untested-file instrumentation iteratescontext.projects(all configured), but the swc transform is registered per-environment only when that environment is built (coverage-istanbul/src/plugin.ts, keyed byenvironment.name). Iterating a configured-but-unbuilt project throwsswc transform function for <env> is not registered. The correct contract is "iterate only the projects built this round," which is latent even for pure-node multi-project configs with an empty sibling.Desired end state
Make browser mode a first-class pool type (like
forks/threads) that flows through the unifiedrun(). Then coverage generation, reporter finalize, and teardown have a single home, the out-of-run()browser coverage branch disappears, and the deferred #1363 coverage gap is resolved for free.Links