supercov 0.0.53 → 0.0.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,7 +49,7 @@ Supercov reports the level it actually observed. It does not guess.
49
49
  | Runner | Attribution |
50
50
  | --- | --- |
51
51
  | Playwright | Exact per test, worker, retry, outcome, action, and assertion phase |
52
- | Vitest | Exact per test, with setup execution kept separate |
52
+ | Vitest | Exact per test, with setup execution kept separate; Browser Mode included |
53
53
  | Jest | Exact per test, including parameterized tests, with the user's own configuration, setup files and reporters kept; passing `expect` occurrences are identified for assertion maps |
54
54
  | `node:test` | Exact per test |
55
55
  | AVA and Mocha | Aggregate structural coverage |
@@ -59,6 +59,51 @@ Supercov reports the level it actually observed. It does not guess.
59
59
  One command may launch several runners. Supercov combines their evidence into
60
60
  one run and preserves runner identity wherever the runner exposes it.
61
61
 
62
+ ### Vitest Browser Mode
63
+
64
+ Vitest Browser Mode runs the test file in a real browser. Supercov measures it
65
+ per test like any other Vitest run: lines, branches, MC/DC and assertion
66
+ coverage. Combined Node/browser projects are configured after Vitest resolves
67
+ inline definitions, config-file references, globs and project selection.
68
+ Evidence travels over Vitest's own browser command channel; the compatibility
69
+ suite exercises Chromium through the Playwright provider.
70
+
71
+ Component code is instrumented the same way as any other source, with one
72
+ addition that matters most here. A JSX tree is a single statement, so an
73
+ expression rendered inside it -- `aria-label={label(state)}`, a child
74
+ `{formatted(value)}` -- is measured on its own rather than counted as covered
75
+ because the component rendered once. This is also what lets a UI assertion be
76
+ explained precisely: `toHaveAccessibleName` names the attribute and
77
+ `toHaveTextContent` names the child, and each is credited separately.
78
+
79
+ Expressions that cannot independently fail to evaluate do not become
80
+ obligations. `{value}` is reached exactly when the tree is, and
81
+ `onClick={() => save()}` is measured where the handler is called rather than
82
+ where it is created.
83
+
84
+ `expect.element(...)`, `expect.soft(...)` and `expect.poll(...)` are recognised
85
+ as assertions, so their passing occurrences are available to assertion maps.
86
+
87
+ ### React and React Native
88
+
89
+ React components can use Testing Library with Vitest/jsdom, Vitest Browser Mode,
90
+ or Babel/Jest. React Native and Expo component tests run through their existing
91
+ Jest presets, including native mocks. This is JavaScript component-test coverage:
92
+ it does not measure Hermes, native modules, simulator/device execution, Detox or
93
+ Maestro. React SSR hydration is exercised in jsdom and Chromium; this does not
94
+ establish Next.js Server Components, streaming SSR or server actions.
95
+
96
+ An assertion map can explain which displayed value, accessible name, disabled
97
+ state or error message a test checks. JSX expression coverage and a passing
98
+ assertion are evidence for reviewing that explanation, not automatic semantic
99
+ proof. A button being rendered does not establish that its disabled state was
100
+ checked. Keep your existing runner and matchers.
101
+
102
+ The [React verification example](https://github.com/supercorp-ai/supercov/tree/main/examples/react-verification)
103
+ shows a fully executed checkout whose weak tests accept four UI regressions,
104
+ then adds four precise assertions and independently checks the broken copies.
105
+ The agent-authored maps credit only those four UI expressions.
106
+
62
107
  ### Builds and source formats
63
108
 
64
109
  JavaScript and TypeScript projects may use Vite, Next, Turbopack, Webpack,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supercov",
3
- "version": "0.0.53",
3
+ "version": "0.0.55",
4
4
  "description": "Coverage for coding agents and software factories \ud83c\udf19",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,6 +54,7 @@
54
54
  "test:rustc-backend-spike": "node scripts/rustc-backend-spike.mjs",
55
55
  "test:rust-compiler-spikes": "cargo build -p supercov && node scripts/rust-libtest-companion-spike.mjs && node scripts/rust-async-attribution-spike.mjs && node scripts/rust-subprocess-attribution-spike.mjs && node scripts/rust-custom-harness-spike.mjs && node scripts/rust-libtest-builder-lifecycle-spike.mjs",
56
56
  "test": "cargo test --workspace",
57
+ "test:react": "cargo build -p supercov && node scripts/rust-vitest-projects-integration.mjs && npm --prefix examples/react-verification ci && npm --prefix examples/react-verification run demo && node scripts/react-hydration-compatibility.mjs",
57
58
  "test:runtime": "node --test tests/runtime/*.test.mjs",
58
59
  "test:fixture": "cargo build -p supercov && node scripts/rust-fixture-matrix.mjs",
59
60
  "test:packed-npx": "cargo build --release -p supercov && node scripts/packed-npx-integration.mjs",
@@ -97,14 +98,14 @@
97
98
  "test:launcher": "go vet ./cmd/... && go test ./cmd/..."
98
99
  },
99
100
  "optionalDependencies": {
100
- "@supercov/cli-darwin-arm64": "0.0.53",
101
- "@supercov/cli-darwin-x64": "0.0.53",
102
- "@supercov/cli-linux-arm64-gnu": "0.0.53",
103
- "@supercov/cli-linux-arm64-musl": "0.0.53",
104
- "@supercov/cli-linux-x64-gnu": "0.0.53",
105
- "@supercov/cli-linux-x64-musl": "0.0.53",
106
- "@supercov/cli-win32-arm64": "0.0.53",
107
- "@supercov/cli-win32-x64": "0.0.53"
101
+ "@supercov/cli-darwin-arm64": "0.0.55",
102
+ "@supercov/cli-darwin-x64": "0.0.55",
103
+ "@supercov/cli-linux-arm64-gnu": "0.0.55",
104
+ "@supercov/cli-linux-arm64-musl": "0.0.55",
105
+ "@supercov/cli-linux-x64-gnu": "0.0.55",
106
+ "@supercov/cli-linux-x64-musl": "0.0.55",
107
+ "@supercov/cli-win32-arm64": "0.0.55",
108
+ "@supercov/cli-win32-x64": "0.0.55"
108
109
  },
109
110
  "peerDependencies": {
110
111
  "@playwright/test": ">=1.55.0",
@@ -126,6 +127,7 @@
126
127
  "@playwright/test": "1.62.1",
127
128
  "@swc/core": "^1.16.1",
128
129
  "@types/node": "^24.0.0",
130
+ "@vitest/browser-playwright": "4.1.11",
129
131
  "esbuild": "^0.28.2",
130
132
  "expect": "30.4.1",
131
133
  "jest": "30.5.1",
@@ -28,8 +28,32 @@ function localFile(path) {
28
28
  return relative(process.cwd(), path).split(sep).join("/");
29
29
  }
30
30
  // Must match jestReporter.mjs: the reporter has the same file and full name.
31
- function testIdentity(testFile, fullName) {
32
- return `jest:${digest(`${testFile}\0${fullName}`)}`;
31
+ function testIdentity(testFile, fullName, occurrence = 0) {
32
+ return `jest:${digest(`${testFile}\0${fullName}${occurrence ? `\0${occurrence}` : ""}`)}`;
33
+ }
34
+ // Circus keeps its declaration tree on the environment global. It distinguishes
35
+ // identical titles and survives retries; expect.currentTestName alone cannot.
36
+ function currentDeclaration() {
37
+ const symbol = Object.getOwnPropertySymbols(globalThis)
38
+ .find(key => key.description === "JEST_STATE_SYMBOL");
39
+ const state = symbol && globalThis[symbol];
40
+ if (!state?.currentlyRunningTest || !state.rootDescribeBlock) return undefined;
41
+ const names = new Map();
42
+ let found;
43
+ function visit(block, parents) {
44
+ for (const child of block.children ?? []) {
45
+ if (child.type === "describeBlock") visit(child, [...parents, child.name]);
46
+ else if (child.type === "test") {
47
+ const name = [...parents, child.name].join(" ");
48
+ const occurrence = names.get(name) ?? 0;
49
+ names.set(name, occurrence + 1);
50
+ if (child === state.currentlyRunningTest)
51
+ found = {occurrence, retry: Math.max((child.invocations ?? 1) - 1, 0)};
52
+ }
53
+ }
54
+ }
55
+ visit(state.rootDescribeBlock, []);
56
+ return found;
33
57
  }
34
58
  // Mirrors provenance.mjs, which is ESM and out of reach here.
35
59
  const KINDS = [
@@ -56,7 +80,7 @@ function writeEvidence(suffix, payload) {
56
80
  renameSync(temporary, target);
57
81
  }
58
82
 
59
- if (runtime && evidenceDirectory && typeof beforeEach === "function" && typeof afterEach === "function") {
83
+ if (runtime && evidenceDirectory && typeof beforeEach === "function" && typeof afterAll === "function") {
60
84
  // One in-memory snapshot per test; the server JSONL transport would be
61
85
  // redundant and unattributed, as under Vitest.
62
86
  runtime.enableRuntimeSnapshotEvidence();
@@ -64,6 +88,7 @@ if (runtime && evidenceDirectory && typeof beforeEach === "function" && typeof a
64
88
  const emittedSetupFiles = new Set();
65
89
  let active;
66
90
  beforeEach(() => {
91
+ flushActive();
67
92
  const state = expect.getState();
68
93
  const testFile = localFile(state.testPath ?? "unknown");
69
94
  if (!emittedSetupFiles.has(testFile)) {
@@ -87,8 +112,9 @@ if (runtime && evidenceDirectory && typeof beforeEach === "function" && typeof a
87
112
  }
88
113
  }
89
114
  const fullName = state.currentTestName ?? "test";
90
- const testId = testIdentity(testFile, fullName);
91
- const retry = attempts.get(testId) ?? 0;
115
+ const declaration = currentDeclaration();
116
+ const testId = testIdentity(testFile, fullName, declaration?.occurrence);
117
+ const retry = declaration?.retry ?? attempts.get(testId) ?? 0;
92
118
  attempts.set(testId, retry + 1);
93
119
  const testKey = digest(testId);
94
120
  const scope = {
@@ -104,7 +130,10 @@ if (runtime && evidenceDirectory && typeof beforeEach === "function" && typeof a
104
130
  runtime.activateCoverageScope(scope);
105
131
  runtime.resetCoverage(testId);
106
132
  });
107
- afterEach(() => {
133
+ // Flush after all user afterEach hooks (including Testing Library cleanup).
134
+ // Our setup registers first, so an afterEach snapshot would run too early.
135
+ // The next beforeEach or this file's afterAll runs after test teardown.
136
+ function flushActive() {
108
137
  const current = active;
109
138
  active = undefined;
110
139
  if (!current)
@@ -130,5 +159,6 @@ if (runtime && evidenceDirectory && typeof beforeEach === "function" && typeof a
130
159
  // snapshot (Vitest isolates files; Jest does not).
131
160
  runtime.resetCoverage();
132
161
  runtime.activateCoverageScope();
133
- });
162
+ }
163
+ afterAll(flushActive);
134
164
  }
@@ -30,7 +30,16 @@ export default async function supercovJestConfig() {
30
30
  ...config,
31
31
  // Concurrent tests in one file would share the worker's scope.
32
32
  maxConcurrency: 1,
33
- setupFilesAfterEnv: [...asList(config.setupFilesAfterEnv), here("jest.cjs")],
33
+ setupFiles: [here("jestRuntime.cjs"), ...asList(config.setupFiles)],
34
+ // Babel-based presets (including React Native) lower the runtime
35
+ // side-effect import in instrumented tests to require(). Jest cannot
36
+ // require our ESM shim. Its environment already owns the runtime, so
37
+ // resolve only our generated import to a CommonJS bridge instead.
38
+ moduleNameMapper: {
39
+ "(?:^|/)\\.supercov/node_modules/runtime\\.mjs$": here("jestRuntime.cjs"),
40
+ ...config.moduleNameMapper,
41
+ },
42
+ setupFilesAfterEnv: [here("jest.cjs"), ...asList(config.setupFilesAfterEnv)],
34
43
  reporters: [
35
44
  ...(config.reporters === undefined ? ["default"] : asList(config.reporters)),
36
45
  here("jestReporter.mjs"),
@@ -30,12 +30,23 @@ function attemptStatus(status) {
30
30
  * record carries the attempt's coverage; the report joins the two.
31
31
  */
32
32
  export default class SupercovJestReporter {
33
- onTestCaseResult(test, result) {
33
+ onTestResult(test, fileResult) {
34
+ // Jest's final array follows declaration order, including skipped cases.
35
+ // Completion order does not: retries can finish after a namesake test.
36
+ const occurrences = new Map();
37
+ for (const result of fileResult.testResults ?? []) {
38
+ const occurrence = occurrences.get(result.fullName) ?? 0;
39
+ occurrences.set(result.fullName, occurrence + 1);
40
+ this.recordResult(test, result, occurrence);
41
+ }
42
+ }
43
+ recordResult(test, result, occurrence) {
34
44
  const evidenceDirectory = process.env["SUPERCOV_EVIDENCE_DIR"];
35
45
  if (!evidenceDirectory)
36
46
  return;
37
47
  const testFile = localFile(test.path);
38
- const testId = `jest:${digest(`${testFile}\0${result.fullName}`)}`;
48
+ const identity = `${testFile}\0${result.fullName}${occurrence ? `\0${occurrence}` : ""}`;
49
+ const testId = `jest:${digest(identity)}`;
39
50
  const retry = Math.max((result.invocations ?? 1) - 1, 0);
40
51
  const status = attemptStatus(result.status);
41
52
  const provenance = inferTestProvenance({
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+
3
+ // Keep Babel-transformed test modules on the same runtime as the Jest adapter.
4
+ // Loading a second runtime here would split assertion and coverage evidence.
5
+ // jsdom owns a separate global and copies process without the preload's
6
+ // private property. Retrieve the host runtime through Node's VM API, then
7
+ // expose it before user setup files can import instrumented application code.
8
+ const runtime = globalThis.__SUPERCOV_DIRECT_RUNTIME__ ?? process.__SUPERCOV_DIRECT_RUNTIME__ ??
9
+ require("node:vm").runInThisContext("globalThis.__SUPERCOV_DIRECT_RUNTIME__");
10
+ if (!runtime) throw new Error("[supercov] Jest runtime was not initialized by the preload");
11
+ globalThis.__SUPERCOV_DIRECT_RUNTIME__ = runtime;
12
+ module.exports = runtime;
@@ -190,22 +190,28 @@ if (isJestEntrypoint && process.env.SUPERCOV_EVIDENCE_DIR) {
190
190
  // Jest reads one configuration. Ours (jest.config.mjs) reads the user's
191
191
  // the way Jest would and adds the adapter and reporter; an explicit
192
192
  // --config on the command line reaches it through the environment.
193
+ const generatedJestConfig = fileURLToPath(new URL("./jest.config.mjs", import.meta.url));
193
194
  for (let index = 2; index < process.argv.length; index += 1) {
194
195
  const argument = process.argv[index];
195
196
  if (argument === "--config" || argument === "-c") {
196
197
  const value = process.argv[index + 1];
197
- if (value)
198
+ // Expo's Jest executable forwards argv to the real Jest process.
199
+ // Preserve the original config across that second preload; reading
200
+ // our own config as the user's would recurse until the heap fills.
201
+ if (value && resolve(value) !== resolve(generatedJestConfig))
198
202
  process.env.SUPERCOV_ORIGINAL_JEST_CONFIG = resolve(value);
199
203
  process.argv.splice(index, value ? 2 : 1);
200
204
  index -= 1;
201
205
  }
202
206
  else if (argument?.startsWith("--config=")) {
203
- process.env.SUPERCOV_ORIGINAL_JEST_CONFIG = resolve(argument.slice("--config=".length));
207
+ const value = resolve(argument.slice("--config=".length));
208
+ if (value !== resolve(generatedJestConfig))
209
+ process.env.SUPERCOV_ORIGINAL_JEST_CONFIG = value;
204
210
  process.argv.splice(index, 1);
205
211
  index -= 1;
206
212
  }
207
213
  }
208
- process.argv.push("--config", fileURLToPath(new URL("./jest.config.mjs", import.meta.url)));
214
+ process.argv.push("--config", generatedJestConfig);
209
215
  }
210
216
  if (generatedPlaywrightConfig &&
211
217
  isPlaywrightEntrypoint) {
@@ -1207,6 +1207,15 @@ function selectionEnd(frame, value) {
1207
1207
  }
1208
1208
  return value;
1209
1209
  }
1210
+ // A JSX expression container has nowhere to put a statement and cannot hold a
1211
+ // bare comma operator, so a rendered expression carries its probe as a call
1212
+ // that returns the value through. Like optionalSelect, it records after the
1213
+ // value evaluates: an expression that throws is reported unevaluated rather
1214
+ // than covered.
1215
+ function renderedValueV2(file, index, value) {
1216
+ coverageHitV2(file, index);
1217
+ return value;
1218
+ }
1210
1219
  function optionalSelect(shortId, continuedId, value) {
1211
1220
  coverageHit(value === null || value === void 0 ? shortId : continuedId);
1212
1221
  return value;
@@ -1356,6 +1365,7 @@ const directRuntimeApi = {
1356
1365
  optionalCallEnd,
1357
1366
  optionalCallReached,
1358
1367
  optionalSelect,
1368
+ renderedValueV2,
1359
1369
  parenthesizedAssignmentValue,
1360
1370
  phaseBelongsToAttempt,
1361
1371
  registerProbeV2,
@@ -1406,6 +1416,7 @@ export {
1406
1416
  optionalCallEnd,
1407
1417
  optionalCallReached,
1408
1418
  optionalSelect,
1419
+ renderedValueV2,
1409
1420
  parenthesizedAssignmentValue,
1410
1421
  phaseBelongsToAttempt,
1411
1422
  registerProbeV2,
@@ -0,0 +1,129 @@
1
+ // Vitest Browser Mode runs the test file in the browser, so the node setup
2
+ // cannot be used: it imports node:fs through atomic.mjs, and Vite externalises
3
+ // that for the client. Loading it fails the whole suite before a single test
4
+ // collects, which is what a browser-mode project saw.
5
+ //
6
+ // This setup mirrors the node one's payload contract exactly. What it cannot do
7
+ // in the browser -- resolve a path against the project root, infer provenance,
8
+ // write a file -- it hands to node over Vitest's browser command channel, so
9
+ // that contract has one implementation.
10
+ import { afterEach, beforeEach } from "vitest";
11
+ import { coverageSnapshot, activateCoverageScope, enableRuntimeSnapshotEvidence, resetCoverage, takeNodeAssertionPhases, } from "./runtime.mjs";
12
+ // Vitest exposes the command channel at "vitest/browser" from v4 and at
13
+ // "@vitest/browser/context" before that. Both are tried so a project is not
14
+ // forced onto one Vitest line to be measured.
15
+ const browserContext = await import("vitest/browser").catch(() =>
16
+ import("@vitest/browser/context"));
17
+ const { commands } = browserContext;
18
+ const attempts = new Map();
19
+ const activeScopes = new Map();
20
+ const emittedSetupFiles = new Set();
21
+ enableRuntimeSnapshotEvidence();
22
+ function attemptStatus(state) {
23
+ if (state === "pass")
24
+ return "passed";
25
+ if (state === "fail")
26
+ return "failed";
27
+ if (state === "skip" || state === "todo")
28
+ return "skipped";
29
+ return "unknown";
30
+ }
31
+ function titlePath(task) {
32
+ const names = [task.name];
33
+ let suite = task.suite;
34
+ while (suite?.name) {
35
+ names.unshift(suite.name);
36
+ suite = suite.suite;
37
+ }
38
+ return names;
39
+ }
40
+ // The node setup keys an attempt by sha256(testId). Browsers have the same
41
+ // digest behind an async API, so the hook awaits it rather than substituting a
42
+ // different hash and giving the same test two identities across environments.
43
+ async function testKeyOf(testId) {
44
+ const bytes = new TextEncoder().encode(testId);
45
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
46
+ return [...new Uint8Array(digest)]
47
+ .map((byte) => byte.toString(16).padStart(2, "0"))
48
+ .join("")
49
+ .slice(0, 24);
50
+ }
51
+ // Evidence leaves the browser over Vitest's own command channel: node runs the
52
+ // handler, the test awaits it, and the failure is reported rather than
53
+ // swallowed -- silently losing a test's evidence is the worst shape a
54
+ // measurement bug takes.
55
+ async function sendEvidence(payload, suffix) {
56
+ try {
57
+ await commands.__supercovEvidence(payload, suffix);
58
+ }
59
+ catch (error) {
60
+ console.error("[supercov] failed to record browser evidence:", error);
61
+ throw error;
62
+ }
63
+ }
64
+ beforeEach(async (context) => {
65
+ const task = context.task;
66
+ // Module imports and shared setup execute before the first test. Save
67
+ // them before resetCoverage clears the snapshot, with a setup identity
68
+ // rather than crediting that execution to the first test.
69
+ if (!emittedSetupFiles.has(task.file.id)) {
70
+ const setupSnapshot = coverageSnapshot();
71
+ if (setupSnapshot.hits.length || setupSnapshot.decisions.length) {
72
+ await sendEvidence({
73
+ testId: `vitest:${task.file.id}:setup`,
74
+ test: `${task.file.name} > module setup`,
75
+ projectName: task.file.projectName,
76
+ title: "module setup",
77
+ retry: 0,
78
+ status: "passed",
79
+ role: "setup",
80
+ runtime: [setupSnapshot],
81
+ browser: [],
82
+ server: [],
83
+ }, `vitest-${task.file.id}-setup`);
84
+ }
85
+ emittedSetupFiles.add(task.file.id);
86
+ }
87
+ const testId = `vitest:${task.id}`;
88
+ const retry = attempts.get(testId) ?? 0;
89
+ attempts.set(testId, retry + 1);
90
+ const testKey = await testKeyOf(testId);
91
+ const scope = {
92
+ version: 1,
93
+ runId: globalThis.__SUPERCOV_RUN_ID__ ?? "unscoped",
94
+ workerId: "vitest-browser",
95
+ testId,
96
+ testKey,
97
+ retry,
98
+ attemptId: `${testKey}-${retry}`,
99
+ };
100
+ activeScopes.set(task.id, scope);
101
+ activateCoverageScope(scope);
102
+ resetCoverage(testId);
103
+ });
104
+ afterEach(async (context) => {
105
+ const task = context.task;
106
+ const scope = activeScopes.get(task.id);
107
+ const retry = scope?.retry ?? task.result?.retryCount ?? 0;
108
+ // Node relativises the test file Vitest reports and infers provenance, so
109
+ // both environments describe a test the same way and neither trusts the
110
+ // browser realm to say which file it was.
111
+ const payload = {
112
+ testId: scope?.testId ?? `vitest:${task.id}`,
113
+ ...(scope ? { scope } : {}),
114
+ test: [...titlePath(task)].join(" > "),
115
+ projectName: task.file?.projectName,
116
+ title: task.name,
117
+ retry,
118
+ status: attemptStatus(task.result?.state),
119
+ ...(scope ? { phases: takeNodeAssertionPhases(scope) } : {}),
120
+ // Browser-mode evidence is a runtime snapshot like node's: the code
121
+ // under test runs in the same realm as the probes.
122
+ runtime: [coverageSnapshot()],
123
+ browser: [],
124
+ server: [],
125
+ };
126
+ await sendEvidence(payload, `vitest-${task.id}-${retry}`);
127
+ activeScopes.delete(task.id);
128
+ activateCoverageScope();
129
+ });
@@ -25,6 +25,15 @@ function rawAttemptStatus(state, expectedFailure) {
25
25
  /** Records final runner outcomes, including tests that never execute hooks. */
26
26
  export default class SupercovVitestReporter {
27
27
  reportedAttempts = new Set();
28
+ constructor(configureProjects) {
29
+ this.configureProjects = configureProjects;
30
+ }
31
+ onInit(vitest) {
32
+ this.configureProjects?.(vitest.projects);
33
+ }
34
+ onBrowserInit(project) {
35
+ this.configureProjects?.([project]);
36
+ }
28
37
  onTestCaseResult(testCase) {
29
38
  const evidenceDirectory = process.env["SUPERCOV_EVIDENCE_DIR"];
30
39
  if (!evidenceDirectory)