supercov 0.0.30 → 0.0.32

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.
package/README.md CHANGED
@@ -38,6 +38,8 @@ npx supercov -- npx playwright test
38
38
  npx supercov -- pnpm test:e2e
39
39
  npx supercov -- cargo test
40
40
  npx supercov -- cargo nextest run
41
+ npx supercov -- pytest
42
+ npx supercov -- python -m unittest
41
43
  ```
42
44
 
43
45
  ## Give Supercov a job
@@ -106,12 +108,12 @@ The denominator comes from source structure before the run, so adding or removin
106
108
  | JavaScript | Available | `npx supercov -- npm test` |
107
109
  | TypeScript | Available | `npx supercov -- npm test` |
108
110
  | Rust | Available | `npx supercov -- cargo test` |
109
- | Python | Coming soon | |
111
+ | Python | Available | `npx supercov -- pytest` |
110
112
  | Zig | Coming soon | — |
111
113
  | PHP | Coming soon | — |
112
114
  | C | Coming soon | — |
113
115
 
114
- Supercov requires Node.js 22 or newer. Rust support currently uses Rust 1.95; cargo-nextest 0.9.138 and 0.9.140 are supported.
116
+ Supercov requires Node.js 22 or newer. Rust support currently uses Rust 1.95; cargo-nextest 0.9.138 and 0.9.140 are supported. Python support requires CPython 3.12 or newer and measures pytest and unittest runs.
115
117
 
116
118
  ## Supported test suites
117
119
 
@@ -9,7 +9,7 @@ npx supercov -- npm test
9
9
  ```
10
10
 
11
11
  No account, config file, import, custom reporter, or hosted service is required.
12
- Supercov supports JavaScript, TypeScript, and Rust today.
12
+ Supercov supports JavaScript, TypeScript, Rust, and Python today.
13
13
 
14
14
  ## Before you start
15
15
 
@@ -17,7 +17,8 @@ You need:
17
17
 
18
18
  - Node.js 22 or newer;
19
19
  - a test command that already works in the repository; and
20
- - for Rust, the Rust 1.95 toolchain.
20
+ - for Rust, the Rust 1.95 toolchain;
21
+ - for Python, CPython 3.12 or newer with pytest or unittest.
21
22
 
22
23
  The CLI is distributed through npm, even for Rust projects. The first `npx`
23
24
  invocation may download Supercov from the npm registry. Supercov itself does not
@@ -37,6 +38,9 @@ npx supercov -- pnpm test:e2e
37
38
  # Rust
38
39
  npx supercov -- cargo test
39
40
  npx supercov -- cargo nextest run
41
+
42
+ # Python
43
+ npx supercov -- pytest
40
44
  ```
41
45
 
42
46
  Supercov runs that command in an isolated, instrumented copy of the project.
@@ -1,13 +1,14 @@
1
1
  # Supported languages and test suites
2
2
 
3
- Supercov supports JavaScript, TypeScript, and Rust today. Start with the same
4
- test command the repository already uses; Supercov detects supported runners
5
- inside that command.
3
+ Supercov supports JavaScript, TypeScript, Rust, and Python today. Start with
4
+ the same test command the repository already uses; Supercov detects supported
5
+ runners inside that command.
6
6
 
7
7
  ```sh
8
8
  npx supercov -- npm test
9
9
  npx supercov -- npx playwright test
10
10
  npx supercov -- cargo test
11
+ npx supercov -- pytest
11
12
  ```
12
13
 
13
14
  ## Language support
@@ -17,7 +18,7 @@ npx supercov -- cargo test
17
18
  | JavaScript | Available | `npx supercov -- npm test` |
18
19
  | TypeScript | Available | `npx supercov -- npm test` |
19
20
  | Rust | Available | `npx supercov -- cargo test` |
20
- | Python | Coming soon | |
21
+ | Python | Available | `npx supercov -- pytest` |
21
22
  | Zig | Coming soon | — |
22
23
  | PHP | Coming soon | — |
23
24
  | C | Coming soon | — |
@@ -66,6 +67,14 @@ Playwright support includes Chromium, Firefox, and WebKit, along with pages,
66
67
  frames, popups, workers, request contexts, WebSockets, and test-launched child
67
68
  processes where the runner exposes their identity.
68
69
 
70
+ Browsers a suite launches itself are covered too. A fixture that calls
71
+ `chromium.launchPersistentContext`, or `launch`/`connect` and hands out its own
72
+ contexts and pages in place of Playwright's `page` fixture, is adopted by each
73
+ test's collector: its pages are read before the fixture closes them, and a
74
+ context kept for the whole worker follows the current test's identity. Actions
75
+ on such pages are not recorded as separate phases, so their evidence is
76
+ attributed to the test and its assertions rather than to individual clicks.
77
+
69
78
  Node child processes inherit coverage automatically. Long-running servers get
70
79
  a short drain window after the test command finishes so buffered evidence can
71
80
  arrive. Work without a reliable test identity is kept as background coverage
@@ -91,6 +100,43 @@ npx supercov -- cargo nextest run --workspace
91
100
  explanation instead of silently falling back to plausible but inaccurate
92
101
  attribution.
93
102
 
103
+ ## Python
104
+
105
+ | Runner | Attribution | Current requirement |
106
+ | --- | --- | --- |
107
+ | pytest | Exact test, worker, retry, and setup/call/teardown phase identity | CPython 3.12 or newer; run with `npx supercov -- pytest` or `python -m pytest` |
108
+ | pytest-xdist | Exact per worker | Workers inherit the run through the environment |
109
+ | pytest-rerunfailures | Exact per attempt; flaky tests are reported as such | |
110
+ | `python -m unittest` | Exact test and setUp/test/tearDown phase identity | Serial in-process; skips and expected failures are recorded; subtest failures roll up to the parent test |
111
+
112
+ Supercov measures Python through CPython's own monitoring interface. Nothing is
113
+ copied, rewritten, or compiled differently: the project runs in place with its
114
+ own interpreter and virtual environment, and Supercov only adds a start-up hook
115
+ through `PYTHONPATH`, a pytest plugin through `PYTEST_PLUGINS`, and a few
116
+ `SUPERCOV_*` variables. Child interpreters started with `subprocess` or
117
+ `multiprocessing` inherit the exact test identity; threads and thread pools
118
+ carry it through `contextvars`.
119
+
120
+ Each interpreter writes commit-framed evidence to a process-owned mmap. A hard
121
+ kill preserves completed observations and an incomplete tail is ignored; an
122
+ exhausted transport or corrupt committed frame fails the run closed.
123
+
124
+ Measured obligations are statements (including several on one line), function
125
+ entry, boolean decisions with MC/DC vectors, `for` and comprehension iteration,
126
+ `and`/`or` short-circuiting, `match` case selection, and `try` completion,
127
+ handler selection and exception propagation, all derived from CPython's own
128
+ instruction positions rather than from exception hooks.
129
+
130
+ Interpreters launched with `-I`, `-E`, or `-S` ignore `PYTHONPATH` and are not
131
+ measured. Code compiled from strings at runtime has no source obligations.
132
+
133
+ ```sh
134
+ npx supercov -- pytest
135
+ npx supercov -- python -m pytest -n 4
136
+ npx supercov -- uv run pytest
137
+ npx supercov -- python -m unittest
138
+ ```
139
+
94
140
  ## Containers, VMs, and remote execution
95
141
 
96
142
  Supercov can collect from supported processes launched through a container, VM,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "supercov",
3
- "version": "0.0.30",
4
- "description": "Zero-edit, runner-aware coverage completeness for JavaScript test suites",
3
+ "version": "0.0.32",
4
+ "description": "Zero-edit, runner-aware coverage completeness for JavaScript, TypeScript, Rust, and Python test suites",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -50,7 +50,7 @@
50
50
  "test:watchdog": "cargo build -p supercov && node scripts/watchdog-integration.mjs",
51
51
  "test:engine-contract": "cargo build -p supercov && node scripts/engine-contract.mjs",
52
52
  "test:agent": "cargo build -p supercov && node scripts/agent-query-eval.mjs",
53
- "test:engine": "cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace && cargo build -p supercov && npm run test:runtime && npm run test:rust-assets && node scripts/rust-process-supervision.mjs && node scripts/rust-direct-node-integration.mjs && node scripts/rust-public-run-integration.mjs && node scripts/rust-embedded-runtime-integration.mjs && node scripts/rust-direct-vitest-integration.mjs && node scripts/rust-direct-playwright-integration.mjs && node scripts/rust-generic-esbuild-integration.mjs && node scripts/rust-generic-tsc-integration.mjs && node scripts/rust-generic-build-matrix.mjs && node scripts/rust-vite-playwright-integration.mjs",
53
+ "test:engine": "cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace && cargo build -p supercov && npm run test:runtime && npm run test:rust-assets && node scripts/rust-process-supervision.mjs && node scripts/rust-direct-node-integration.mjs && node scripts/rust-public-run-integration.mjs && node scripts/rust-embedded-runtime-integration.mjs && node scripts/rust-direct-vitest-integration.mjs && node scripts/rust-direct-playwright-integration.mjs && node scripts/rust-custom-browser-playwright-integration.mjs && node scripts/rust-generic-esbuild-integration.mjs && node scripts/rust-generic-tsc-integration.mjs && node scripts/rust-generic-build-matrix.mjs && node scripts/rust-vite-playwright-integration.mjs",
54
54
  "test:platform": "cargo test --workspace && cargo build -p supercov && node scripts/rust-process-supervision.mjs && node scripts/workspace-crash-integration.mjs",
55
55
  "test:native-package": "cargo build --release -p supercov && node scripts/native-package-integration.mjs && node scripts/native-release-set-integration.mjs",
56
56
  "test:pypi-wheel": "node scripts/pypi-wheel-integration.mjs",
@@ -62,18 +62,20 @@
62
62
  "test:clang-mcdc": "node scripts/clang-mcdc-oracle.mjs",
63
63
  "test:test262": "cargo build --release -p supercov && node scripts/test262-equivalence.mjs",
64
64
  "benchmark:check": "cargo build --release -p supercov && node scripts/rust-transform-benchmark.mjs",
65
+ "benchmark:python-monitoring": "cargo build -p supercov && node scripts/python-monitoring-benchmark.mjs",
65
66
  "check": "cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && npm run test && npm run test:runtime && npm run test:rust-assets && node scripts/package-preflight.mjs",
66
- "release:check": "npm run check && npm run test:engine && npm run test:fixture && npm run test:watchdog && npm run test:engine-contract && npm run test:agent && npm run test:packed-npx && npm run test:clang-mcdc && npm run benchmark:check",
67
+ "release:check": "npm run check && npm run test:engine && npm run test:fixture && npm run test:watchdog && npm run test:engine-contract && npm run test:agent && npm run test:python-monitoring && npm run test:packed-npx && npm run test:clang-mcdc && npm run benchmark:check",
67
68
  "prepack": "node scripts/package-preflight.mjs",
68
- "prepublishOnly": "npm run release:check"
69
+ "prepublishOnly": "npm run release:check",
70
+ "test:python-monitoring": "cargo build -p supercov && node scripts/python-monitoring-integration.mjs"
69
71
  },
70
72
  "optionalDependencies": {
71
- "@supercov/cli-darwin-arm64": "0.0.30",
72
- "@supercov/cli-darwin-x64": "0.0.30",
73
- "@supercov/cli-linux-arm64-gnu": "0.0.30",
74
- "@supercov/cli-linux-arm64-musl": "0.0.30",
75
- "@supercov/cli-linux-x64-gnu": "0.0.30",
76
- "@supercov/cli-linux-x64-musl": "0.0.30"
73
+ "@supercov/cli-darwin-arm64": "0.0.32",
74
+ "@supercov/cli-darwin-x64": "0.0.32",
75
+ "@supercov/cli-linux-arm64-gnu": "0.0.32",
76
+ "@supercov/cli-linux-arm64-musl": "0.0.32",
77
+ "@supercov/cli-linux-x64-gnu": "0.0.32",
78
+ "@supercov/cli-linux-x64-musl": "0.0.32"
77
79
  },
78
80
  "peerDependencies": {
79
81
  "@playwright/test": ">=1.55.0",
@@ -168,6 +168,20 @@ class CoveragePhaseController {
168
168
  scriptUpdate = Promise.resolve();
169
169
  proxyCache = new WeakMap();
170
170
  runtimeSnapshots;
171
+ // Contexts this controller found rather than created: it does not know
172
+ // what `extraHTTPHeaders` their owner configured, so it must not rewrite
173
+ // them (the scope cookie and the fetch patch still carry attribution).
174
+ adoptedContexts = new Set();
175
+ // Listeners installed on contexts that outlive this test, removed on
176
+ // dispose so a worker-scoped context does not keep registering pages with
177
+ // controllers of tests that already finished.
178
+ contextListeners = new Map();
179
+ // Snapshots read from pages the suite closed before teardown. A `page`
180
+ // fixture override tears down before the collector's own fixture, and a
181
+ // closed page cannot be evaluated, so the evidence is taken on the way out.
182
+ earlySnapshots = [];
183
+ snapshottedPages = new WeakSet();
184
+ disposed = false;
171
185
  // Parameter properties are stateful despite the base ESLint rule treating
172
186
  // this as an empty constructor.
173
187
  // eslint-disable-next-line no-useless-constructor
@@ -185,21 +199,15 @@ class CoveragePhaseController {
185
199
  if (this.runtimeSnapshots)
186
200
  return this.runtimeSnapshots;
187
201
  timingCount("collectRuntimeSnapshots.cold");
188
- const snapshots = [];
202
+ // Contexts the suite created without going through any fixture the
203
+ // collector wraps (a raw `browser.newContext()` mid-test) are found
204
+ // through their browser now, so their still-open pages are read too.
205
+ await this.adoptTrackedContexts().catch(() => undefined);
206
+ const snapshots = [...this.earlySnapshots];
189
207
  for (const page of this.allPages()) {
190
- for (const frame of page.frames()) {
191
- const snapshot = await frame
192
- .evaluate(() => {
193
- const getSnapshot = globalThis.__SUPERCOV_COVERAGE_SNAPSHOT__;
194
- return getSnapshot?.() ?? { decisions: [], hits: [], events: [] };
195
- })
196
- .catch(() => ({
197
- decisions: [],
198
- hits: [],
199
- events: [],
200
- }));
201
- snapshots.push(snapshot);
202
- }
208
+ if (this.snapshottedPages.has(page))
209
+ continue;
210
+ snapshots.push(...(await this.snapshotPage(page)));
203
211
  }
204
212
  for (const worker of this.allWorkers()) {
205
213
  const snapshot = await worker
@@ -217,12 +225,50 @@ class CoveragePhaseController {
217
225
  this.runtimeSnapshots = snapshots;
218
226
  return snapshots;
219
227
  }
228
+ async snapshotPage(page) {
229
+ const snapshots = [];
230
+ for (const frame of page.frames()) {
231
+ snapshots.push(await frame
232
+ .evaluate(() => {
233
+ const getSnapshot = globalThis.__SUPERCOV_COVERAGE_SNAPSHOT__;
234
+ return getSnapshot?.() ?? { decisions: [], hits: [], events: [] };
235
+ })
236
+ .catch(() => ({
237
+ decisions: [],
238
+ hits: [],
239
+ events: [],
240
+ })));
241
+ }
242
+ return snapshots;
243
+ }
244
+ /** Read a registered page's evidence while it can still be evaluated. */
245
+ async snapshotBeforeClose(page) {
246
+ if (!this.pages.has(page) || this.snapshottedPages.has(page) || this.runtimeSnapshots)
247
+ return;
248
+ this.snapshottedPages.add(page);
249
+ this.earlySnapshots.push(...(await this.snapshotPage(page)));
250
+ }
251
+ /**
252
+ * Register every live context the process created outside the wrapped
253
+ * fixtures: contexts launched directly (`launchPersistentContext`) and
254
+ * every context of every browser launched or connected directly.
255
+ */
256
+ async adoptTrackedContexts() {
257
+ if (this.disposed)
258
+ return;
259
+ for (const context of liveTrackedContexts()) {
260
+ if (!this.contexts.has(context))
261
+ await this.registerContext(context, this.configuredHeaders, { adopted: true }).catch(() => undefined);
262
+ }
263
+ }
220
264
  async registerPage(page) {
221
- if (this.pages.has(page))
265
+ if (this.pages.has(page) || this.disposed)
222
266
  return;
223
267
  timingCount("registerPage");
224
268
  this.pages.add(page);
225
- await this.registerContext(page.context());
269
+ await this.registerContext(page.context(), this.configuredHeaders, {
270
+ adopted: !this.contexts.has(page.context()) && liveTrackedContexts().has(page.context()),
271
+ });
226
272
  const cdp = await page.context().newCDPSession(page).catch(() => undefined);
227
273
  if (cdp)
228
274
  this.cdpSessions.set(page, cdp);
@@ -235,26 +281,40 @@ class CoveragePhaseController {
235
281
  for (const worker of page.workers())
236
282
  void this.registerWorker(worker);
237
283
  }
238
- async registerContext(context, configuredHeaders = this.configuredHeaders) {
239
- if (this.contexts.has(context))
284
+ async registerContext(context, configuredHeaders = this.configuredHeaders, { adopted = false } = {}) {
285
+ if (this.contexts.has(context) || this.disposed)
240
286
  return;
241
287
  this.contexts.add(context);
242
288
  this.contextConfiguredHeaders.set(context, configuredHeaders);
289
+ if (adopted)
290
+ this.adoptedContexts.add(context);
291
+ // A context that outlives its test (a worker-scoped browser fixture)
292
+ // is registered again by every later test, so this script accumulates
293
+ // on it and each new document runs every copy in registration order.
294
+ // That is made harmless rather than avoided: every copy publishes its
295
+ // own attempt, so the newest wins; the fetch patch is installed once
296
+ // and reads the attempt at call time, so a stale copy can never pin an
297
+ // old scope onto a request the way nested wrappers would.
243
298
  await context.addInitScript(({ attemptId, scopeHeader, scopeValue, scopeCookie }) => {
244
- globalThis.__SUPERCOV_MCDC_TEST_ID__ = attemptId;
299
+ const coverageGlobal = globalThis;
300
+ coverageGlobal.__SUPERCOV_ATTEMPT__ = { attemptId, scopeValue };
301
+ coverageGlobal.__SUPERCOV_MCDC_TEST_ID__ = attemptId;
245
302
  try {
246
303
  document.cookie = `${scopeCookie}=${encodeURIComponent(scopeValue)}; Path=/; SameSite=Lax`;
247
- const originalFetch = globalThis.fetch?.bind(globalThis);
248
- if (originalFetch) {
249
- globalThis.fetch = ((input, init) => {
250
- const headers = new Headers(init?.headers ??
251
- (input instanceof Request ? input.headers : undefined));
252
- headers.set(scopeHeader, scopeValue);
253
- const phase = globalThis.__SUPERCOV_PHASE_ID__;
254
- if (phase)
255
- headers.set("x-supercov-phase", phase);
256
- return originalFetch(input, { ...init, headers });
257
- });
304
+ if (!coverageGlobal.__SUPERCOV_BROWSER_FETCH_PATCHED__) {
305
+ const originalFetch = globalThis.fetch?.bind(globalThis);
306
+ if (originalFetch) {
307
+ coverageGlobal.__SUPERCOV_BROWSER_FETCH_PATCHED__ = true;
308
+ globalThis.fetch = ((input, init) => {
309
+ const headers = new Headers(init?.headers ??
310
+ (input instanceof Request ? input.headers : undefined));
311
+ headers.set(scopeHeader, coverageGlobal.__SUPERCOV_ATTEMPT__?.scopeValue ?? scopeValue);
312
+ const phase = coverageGlobal.__SUPERCOV_PHASE_ID__;
313
+ if (phase)
314
+ headers.set("x-supercov-phase", phase);
315
+ return originalFetch(input, { ...init, headers });
316
+ });
317
+ }
258
318
  }
259
319
  }
260
320
  catch {
@@ -265,18 +325,21 @@ class CoveragePhaseController {
265
325
  scopeHeader: COVERAGE_SCOPE_HEADER,
266
326
  scopeValue: encodeCoverageScope(this.scope),
267
327
  scopeCookie: COVERAGE_SCOPE_COOKIE,
268
- });
328
+ }).catch(() => undefined);
269
329
  const register = (page) => {
270
330
  const pending = this.registerPage(page).finally(() => this.pendingRegistrations.delete(pending));
271
331
  this.pendingRegistrations.add(pending);
272
332
  };
273
- context.on("page", register);
274
- context.on("serviceworker", (worker) => {
333
+ const registerServiceWorker = (worker) => {
275
334
  void this.registerWorker(worker);
276
- });
335
+ };
336
+ context.on("page", register);
337
+ context.on("serviceworker", registerServiceWorker);
338
+ this.contextListeners.set(context, [["page", register], ["serviceworker", registerServiceWorker]]);
277
339
  for (const worker of context.serviceWorkers())
278
340
  void this.registerWorker(worker);
279
- await this.updateContextHeaders(context, this.requestPhaseId());
341
+ if (!adopted)
342
+ await this.updateContextHeaders(context, this.requestPhaseId());
280
343
  for (const page of context.pages())
281
344
  register(page);
282
345
  }
@@ -333,8 +396,14 @@ class CoveragePhaseController {
333
396
  return this.activePhaseId;
334
397
  }
335
398
  async dispose() {
399
+ this.disposed = true;
336
400
  await Promise.all([...this.pendingRegistrations]);
337
401
  await this.scriptUpdate;
402
+ for (const [context, listeners] of this.contextListeners) {
403
+ for (const [event, listener] of listeners)
404
+ context.off?.(event, listener);
405
+ }
406
+ this.contextListeners.clear();
338
407
  for (const [page, cdp] of this.cdpSessions) {
339
408
  const identifier = this.newDocumentScriptIds.get(page);
340
409
  if (identifier)
@@ -431,10 +500,15 @@ class CoveragePhaseController {
431
500
  ArrayBuffer.isView(result))
432
501
  return result;
433
502
  const candidate = result;
503
+ if (typeof candidate["contexts"] === "function" &&
504
+ typeof candidate["newContext"] === "function")
505
+ trackBrowser(result);
434
506
  if (typeof candidate["pages"] === "function" &&
435
- typeof candidate["route"] === "function")
507
+ typeof candidate["route"] === "function") {
508
+ trackContext(result);
436
509
  await this.registerContext(result, (sourceArgs?.[0]
437
510
  ?.extraHTTPHeaders ?? this.configuredHeaders));
511
+ }
438
512
  if (typeof candidate["frames"] === "function" &&
439
513
  typeof candidate["context"] === "function")
440
514
  await this.registerPage(result);
@@ -472,12 +546,18 @@ class CoveragePhaseController {
472
546
  return scoped;
473
547
  }
474
548
  async activateInBrowser(phaseId) {
475
- await timed("activate.contextHeaders", () => Promise.all([...this.contexts].map((context) => this.updateContextHeaders(context, phaseId))));
549
+ await timed("activate.contextHeaders", () => Promise.all([...this.contexts]
550
+ .filter((context) => !this.adoptedContexts.has(context))
551
+ .map((context) => this.updateContextHeaders(context, phaseId))));
476
552
  await timed("activate.frameEvaluate", () => Promise.all([...this.pages].flatMap((page) => page.frames()).map((frame) => frame
477
- .evaluate(({ id, storageKey, scopeCookie, scopeValue, phaseCookie }) => {
553
+ .evaluate(({ id, attemptId, storageKey, scopeCookie, scopeValue, phaseCookie }) => {
478
554
  globalThis.__SUPERCOV_PHASE_ID__ = id;
479
555
  const coverageGlobal = globalThis;
480
- coverageGlobal.__SUPERCOV_ACTIVATE_PROBE_CONTEXT__?.(coverageGlobal.__SUPERCOV_MCDC_TEST_ID__ ?? "unscoped", id);
556
+ // A document that loaded under an earlier test's attempt (a page
557
+ // adopted mid-life from a shared context) follows the current one.
558
+ coverageGlobal.__SUPERCOV_ATTEMPT__ = { attemptId, scopeValue };
559
+ coverageGlobal.__SUPERCOV_MCDC_TEST_ID__ = attemptId;
560
+ coverageGlobal.__SUPERCOV_ACTIVATE_PROBE_CONTEXT__?.(attemptId, id);
481
561
  try {
482
562
  localStorage.setItem(storageKey, id);
483
563
  document.cookie = `${scopeCookie}=${encodeURIComponent(scopeValue)}; Path=/; SameSite=Lax`;
@@ -488,6 +568,7 @@ class CoveragePhaseController {
488
568
  }
489
569
  }, {
490
570
  id: phaseId,
571
+ attemptId: this.scope.attemptId,
491
572
  storageKey: PHASE_STORAGE_KEY,
492
573
  scopeCookie: COVERAGE_SCOPE_COOKIE,
493
574
  scopeValue: encodeCoverageScope(this.scope),
@@ -538,6 +619,129 @@ class CoveragePhaseController {
538
619
  let activeController;
539
620
  let bridgedAssertionDepth = 0;
540
621
  const controllers = new Map();
622
+ // Browsers and contexts the process created without any fixture the collector
623
+ // wraps. Test harnesses routinely launch their own browser in a worker-scoped
624
+ // fixture (`chromium.launchPersistentContext` for a shared profile,
625
+ // `chromium.launch`/`connect` plus `browser.newContext()` per test) and
626
+ // override `page` on top; those objects never pass through the `browser`/`page`
627
+ // fixtures, and imports made from inside node_modules are never redirected to
628
+ // this shim, so wrapping exports would not see them either. Every page they
629
+ // opened ran unmeasured. The Playwright classes are patched below so such
630
+ // launches are recorded here, and each test's controller adopts what is live.
631
+ const trackedBrowsers = new Set();
632
+ const trackedContexts = new Set();
633
+ const patchedPrototypes = new WeakSet();
634
+ function trackBrowser(browser) {
635
+ if (!browser || typeof browser !== "object" || trackedBrowsers.has(browser))
636
+ return;
637
+ trackedBrowsers.add(browser);
638
+ browser.once?.("disconnected", () => trackedBrowsers.delete(browser));
639
+ patchBrowserPrototype(browser);
640
+ }
641
+ function trackContext(context) {
642
+ if (!context || typeof context !== "object" || trackedContexts.has(context))
643
+ return;
644
+ trackedContexts.add(context);
645
+ context.once?.("close", () => trackedContexts.delete(context));
646
+ patchContextPrototype(context);
647
+ }
648
+ function liveTrackedContexts() {
649
+ const contexts = new Set(trackedContexts);
650
+ for (const browser of trackedBrowsers) {
651
+ try {
652
+ for (const context of browser.contexts())
653
+ contexts.add(context);
654
+ }
655
+ catch {
656
+ // A browser that disconnected between the event and this sweep.
657
+ }
658
+ }
659
+ return contexts;
660
+ }
661
+ /** The controller that registered `page`, whichever test it belongs to. */
662
+ function controllerOwning(page) {
663
+ if (activeController?.pages.has(page))
664
+ return activeController;
665
+ for (const controller of controllers.values())
666
+ if (controller.pages.has(page))
667
+ return controller;
668
+ return undefined;
669
+ }
670
+ function patchOnce(target, method, replace) {
671
+ const prototype = target && Object.getPrototypeOf(target);
672
+ if (!prototype || typeof prototype[method] !== "function")
673
+ return;
674
+ const marker = `__SUPERCOV_PATCHED_${method}__`;
675
+ if (prototype[marker])
676
+ return;
677
+ Object.defineProperty(prototype, marker, { value: true });
678
+ prototype[method] = replace(prototype[method]);
679
+ }
680
+ /** Record every context a directly launched or connected browser creates. */
681
+ function patchBrowserPrototype(browser) {
682
+ const prototype = Object.getPrototypeOf(browser);
683
+ if (!prototype || patchedPrototypes.has(prototype))
684
+ return;
685
+ patchedPrototypes.add(prototype);
686
+ patchOnce(browser, "newContext", (original) => async function (...args) {
687
+ const context = await original.apply(this, args);
688
+ trackContext(context);
689
+ const controller = activeController;
690
+ if (controller && !controller.contexts.has(context))
691
+ await controller.registerContext(context, args[0]?.extraHTTPHeaders ?? controller.configuredHeaders, { adopted: true }).catch(() => undefined);
692
+ return context;
693
+ });
694
+ }
695
+ /**
696
+ * Read a page's evidence before the suite closes it. A `page` fixture defined
697
+ * downstream of this shim tears down before the collector's own fixture, and
698
+ * a customer context opened mid-test is usually closed in a `finally`; either
699
+ * way the page is gone by the time the test's snapshots are collected.
700
+ */
701
+ function patchContextPrototype(context) {
702
+ patchOnce(context, "close", (original) => async function (...args) {
703
+ for (const page of this.pages()) {
704
+ await controllerOwning(page)?.snapshotBeforeClose(page).catch(() => undefined);
705
+ }
706
+ return original.apply(this, args);
707
+ });
708
+ for (const page of context.pages())
709
+ patchPagePrototype(page);
710
+ context.on?.("page", patchPagePrototype);
711
+ }
712
+ function patchPagePrototype(page) {
713
+ patchOnce(page, "close", (original) => async function (...args) {
714
+ await controllerOwning(this)?.snapshotBeforeClose(this).catch(() => undefined);
715
+ return original.apply(this, args);
716
+ });
717
+ }
718
+ /**
719
+ * Patch the browser types' launch and connect paths on their shared prototype
720
+ * so every browser or context the process creates is tracked, whichever module
721
+ * path imported them. When a test is running its controller registers the new
722
+ * object at once; otherwise the next controller adopts it.
723
+ */
724
+ function installBrowserLaunchTracking() {
725
+ const browserType = standardPlaywright.chromium ?? standardPlaywright.firefox ?? standardPlaywright.webkit;
726
+ if (!browserType)
727
+ return;
728
+ for (const method of ["launch", "connect", "connectOverCDP"]) {
729
+ patchOnce(browserType, method, (original) => async function (...args) {
730
+ const browser = await original.apply(this, args);
731
+ trackBrowser(browser);
732
+ return browser;
733
+ });
734
+ }
735
+ patchOnce(browserType, "launchPersistentContext", (original) => async function (...args) {
736
+ const context = await original.apply(this, args);
737
+ trackContext(context);
738
+ const controller = activeController;
739
+ if (controller)
740
+ await controller.registerContext(context, args[1]?.extraHTTPHeaders ?? controller.configuredHeaders, { adopted: true }).catch(() => undefined);
741
+ return context;
742
+ });
743
+ }
744
+ installBrowserLaunchTracking();
541
745
  const directRuntime = () => globalThis.__SUPERCOV_DIRECT_RUNTIME__ ?? coverageRuntime;
542
746
  globalThis.__SUPERCOV_ASSERTION_PHASE_BRIDGE__ = (operation, source, callback) => {
543
747
  const controller = activeController;
@@ -839,7 +1043,7 @@ function mergeCollectedPhases(controllerPhases, fallbackPhases) {
839
1043
  });
840
1044
  return [...controllerPhases, ...remaining];
841
1045
  }
842
- const instrumentedTest = base.extend({
1046
+ const instrumentedFixtures = {
843
1047
  page: async ({ page }, use, testInfo) => {
844
1048
  const scope = executionScope(testInfo);
845
1049
  const controller = controllers.get(scope.attemptId);
@@ -887,6 +1091,7 @@ const instrumentedTest = base.extend({
887
1091
  controllers.set(scope.attemptId, controller);
888
1092
  activeController = controller;
889
1093
  directRuntime()?.activateCoverageScope(scope);
1094
+ await controller.adoptTrackedContexts().catch(() => undefined);
890
1095
  const serverOutput = serverEvidencePath(scope);
891
1096
  mkdirSync(serverEvidenceDirectory(scope), { recursive: true });
892
1097
  rmSync(serverOutput, { force: true });
@@ -951,6 +1156,31 @@ const instrumentedTest = base.extend({
951
1156
  },
952
1157
  { auto: true },
953
1158
  ],
954
- });
1159
+ };
1160
+ const instrumentedTest = base.extend(instrumentedFixtures);
955
1161
  export const test = instrumentedTest;
1162
+ /**
1163
+ * A Playwright `test` object: callable, and carrying the API a spec drives.
1164
+ * Facades export several -- one per fixture set -- and every one of them must
1165
+ * collect. Instrumenting only the discovered export left a real suite's
1166
+ * storefront fixture entirely unmeasured: its 20 tests ran with no controller,
1167
+ * so nothing they executed was ever read back.
1168
+ */
1169
+ function isPlaywrightTest(value) {
1170
+ return typeof value === "function" &&
1171
+ typeof value.extend === "function" &&
1172
+ typeof value.describe === "function" &&
1173
+ typeof value.use === "function";
1174
+ }
1175
+ /**
1176
+ * Re-export one of the facade's values. A test object is extended with the
1177
+ * collector's fixtures; the overrides compose with the facade's own (`page`
1178
+ * receives the facade's page and wraps it), so a custom browser fixture keeps
1179
+ * working and is measured. Anything else passes through untouched.
1180
+ */
1181
+ export function __supercovAdapterExport(value) {
1182
+ if (!isPlaywrightTest(value))
1183
+ return value;
1184
+ return value === base ? instrumentedTest : value.extend(instrumentedFixtures);
1185
+ }
956
1186
  /*__SUPERCOV_ADAPTER_EXPORTS__*/
@@ -808,14 +808,28 @@ function installServerChildPropagation() {
808
808
  runtimeGlobal.__SUPERCOV_CHILD_PATCHED__ = true;
809
809
  }
810
810
  installServerChildPropagation();
811
+ /**
812
+ * Phase ids are minted as `<attemptId>:phase:<n>`, so a phase can be checked
813
+ * against the attempt it claims to belong to without a lookup.
814
+ */
815
+ function phaseBelongsToAttempt(phaseId, attemptId) {
816
+ return typeof phaseId === "string" && typeof attemptId === "string" && attemptId.length > 0 && phaseId.startsWith(`${attemptId}:phase:`);
817
+ }
811
818
  function currentPhaseId() {
812
819
  if (runtimeGlobal.__SUPERCOV_PHASE_ID__)
813
820
  return runtimeGlobal.__SUPERCOV_PHASE_ID__;
814
821
  if (!isBrowser)
815
822
  return currentRequestContext().phaseId;
816
823
  try {
824
+ // The stored phase is per origin, so in a browser context that outlives
825
+ // its test (a persistent profile shared by a whole worker) it still holds
826
+ // the previous test's last phase when the next test's document loads. A
827
+ // phase from another attempt would tag this test's evidence with a phase
828
+ // it never reported, which the archive rightly rejects; only a phase of
829
+ // the current attempt is honoured.
817
830
  const local = localStorage.getItem(phaseStorageKey);
818
- if (local)
831
+ const attemptId = runtimeGlobal.__SUPERCOV_MCDC_TEST_ID__ != null ? runtimeGlobal.__SUPERCOV_MCDC_TEST_ID__ : testId;
832
+ if (local && phaseBelongsToAttempt(local, attemptId))
819
833
  return local;
820
834
  return void 0;
821
835
  } catch (e) {
@@ -870,7 +884,10 @@ function requestCoverageContext(value) {
870
884
  const encodedScope = (_a8 = headers.get(COVERAGE_SCOPE_HEADER)) != null ? _a8 : cookies.get(COVERAGE_SCOPE_COOKIE);
871
885
  const rawPhaseId = (_b = headers.get(COVERAGE_PHASE_HEADER)) != null ? _b : cookies.get(COVERAGE_PHASE_COOKIE);
872
886
  const scope = decodeCoverageScope(typeof encodedScope === "string" ? encodedScope : void 0);
873
- const phaseId = typeof rawPhaseId === "string" && rawPhaseId.length > 0 ? rawPhaseId : void 0;
887
+ // The phase cookie outlives a test in a shared browser context just like
888
+ // the stored phase does (see currentPhaseId); a request carrying a scope
889
+ // and a phase from different attempts keeps the scope and drops the phase.
890
+ const phaseId = typeof rawPhaseId === "string" && rawPhaseId.length > 0 && (!scope || phaseBelongsToAttempt(rawPhaseId, scope.attemptId)) ? rawPhaseId : void 0;
874
891
  return __spreadValues(__spreadValues({}, scope ? { scope } : {}), phaseId ? { phaseId } : {});
875
892
  }
876
893
  function withRequestPhase(handler) {
@@ -1186,6 +1203,7 @@ const directRuntimeApi = {
1186
1203
  optionalCallReached,
1187
1204
  optionalSelect,
1188
1205
  parenthesizedAssignmentValue,
1206
+ phaseBelongsToAttempt,
1189
1207
  registerProbeV2,
1190
1208
  resetCoverage,
1191
1209
  selectionBegin,
@@ -1233,6 +1251,7 @@ export {
1233
1251
  optionalCallReached,
1234
1252
  optionalSelect,
1235
1253
  parenthesizedAssignmentValue,
1254
+ phaseBelongsToAttempt,
1236
1255
  registerProbeV2,
1237
1256
  resetCoverage,
1238
1257
  selectionBegin,