supercov 4.9.0 → 4.11.0

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.
@@ -429,13 +429,52 @@ server is built from that copy. Go has no per-request context Supercov can
429
429
  carry a test's name in to every probe, so the server sweeps what ran every
430
430
  5 ms, and each sweep is credited to the browser test that was running then,
431
431
  if only one was, and said to be. Work done while two tests ran at once, as
432
- with parallel workers, is the run's and no test's. The server's coverage is a
432
+ with parallel workers, is the run's and no test's; the run says how many tests
433
+ that left without their server work, and `--workers=1` gives each test its
434
+ own. The server's coverage is a
433
435
  Go run of its own, published as the run or beside the browser run, as for a
434
436
  [Python server](#a-python-server-under-browser-tests).
435
437
 
436
438
  A server binary built before the run is not the one Supercov instruments:
437
439
  start it with `go run`, or build it in the test command.
438
440
 
441
+ ### A Java or Kotlin server under browser tests
442
+
443
+ A Playwright suite whose server is built by its own command (`./gradlew
444
+ bootRun`, `mvn spring-boot:run`, or `javac` and `java`) measures it the way it
445
+ measures a [Go server](#a-go-server-under-browser-tests): the copy's Java and
446
+ Kotlin are rewritten with probes, the runtime goes in each module's main
447
+ source set, and what the server ran is swept every 5 ms and credited by time
448
+ to the browser test running then, if only one was. A jar built before the run
449
+ is not the one Supercov instruments.
450
+
451
+ ```sh
452
+ npx supercov -- npx playwright test
453
+ ```
454
+
455
+ ### A server Docker Compose builds
456
+
457
+ A Playwright suite whose server `docker compose up` builds (`build:` in the
458
+ compose file) measures it in any of these languages: Node, Python, Ruby, Go,
459
+ Java or Kotlin. The image is built from Supercov's copy, so it holds the
460
+ copy's code. Supercov's `docker` on the suite's PATH gives each service
461
+ built from the copy the run's settings, and mounts the project's `.supercov`
462
+ into it at the same path, so the server writes its coverage where the run
463
+ reads it. Each request or window is credited to its test as for a server
464
+ started directly. Supercov builds those services before starting them, so
465
+ an image left over from an earlier run is never used. When the command ends
466
+ it stops the containers it started; it never removes them.
467
+
468
+ - Services that pull an image (`image:` with no `build:`) run code nobody
469
+ instrumented, and are left as they are.
470
+ - A Python or Ruby image may keep the code at a path of its own
471
+ (`COPY backend/ /srv/`): the runtime matches the image's files to the
472
+ copy's by their paths' common ending, and only when several of them match.
473
+ - A Go image needs Supercov's runtime package, `_supercov_runtime/` at the
474
+ module root: copy the whole module (`COPY . .`), not chosen directories.
475
+ - `docker run` and a `docker build` the command makes itself are not
476
+ covered; neither is Windows.
477
+
439
478
  ```sh
440
479
  npx supercov -- npx playwright test
441
480
  ```
@@ -85,6 +85,16 @@ When a run fails in a project that has a `build` script the command does not
85
85
  reach, it says this. `SUPERCOV_KEEP_WORKSPACE=1` leaves the instrumented copy
86
86
  in `.supercov/workspaces/` after a run, to inspect.
87
87
 
88
+ A package inside a larger git repository (`web/` beside `cmd/`, or
89
+ `packages/app` in a monorepo) is copied alone, but the copy sits at the
90
+ package's path in the repository, with the repository's other files and
91
+ directories linked beside it from your checkout. A suite that reaches outside
92
+ the package, a `webServer` running `go run ../cmd/testserver` or a tsconfig
93
+ extending `../../tsconfig.base.json`, finds them where it finds them without
94
+ Supercov. They are your checkout's own files, not copies, and they are not
95
+ measured; the repository's `.git` and lockfiles are never linked. To measure code outside the
96
+ package too, run Supercov from the repository root.
97
+
88
98
  ## Files Supercov creates
89
99
 
90
100
  | Location | What it is for |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supercov",
3
- "version": "4.9.0",
3
+ "version": "4.11.0",
4
4
  "description": "Coverage, security and code quality for coding agents",
5
5
  "keywords": [
6
6
  "coverage",
@@ -83,7 +83,7 @@
83
83
  "eval:skill": "claude plugin eval plugins/supercov --scaffold --trust-plugin --no-publish --model claude-sonnet-5 --judge-model claude-haiku-4-5 --threshold 0.66 --tag behavior trigger --allow-tools Bash Edit Write --max-cost-usd 15",
84
84
  "eval:skill-triggers": "claude plugin eval plugins/supercov --scaffold --trust-plugin --no-publish --model claude-sonnet-5 --judge-model claude-haiku-4-5 --threshold 0.66 --tag trigger --ablation none --max-cost-usd 5",
85
85
  "test:engine": "cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace && npm run test:runtime && npm run test:rust-assets && npm run test:engine-integrations",
86
- "test:engine-integrations": "cargo build -p supercov && node scripts/html-report-integration.mjs && 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-jest-integration.mjs && node scripts/mocha-failure-output.mjs && node scripts/failure-output.mjs node ava tap && node scripts/side-by-side.mjs node && node scripts/merge-across.mjs && node scripts/rust-direct-playwright-integration.mjs && node scripts/rust-custom-browser-playwright-integration.mjs && node scripts/cross-origin-requests.mjs && node scripts/background-by-time.mjs && node scripts/python-server-playwright.mjs && node scripts/ruby-server-playwright.mjs && node scripts/go-server-playwright.mjs && node scripts/rust-classic-browser-integration.mjs && node scripts/rust-top-packages-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",
86
+ "test:engine-integrations": "cargo build -p supercov && node scripts/html-report-integration.mjs && 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-jest-integration.mjs && node scripts/mocha-failure-output.mjs && node scripts/failure-output.mjs node ava tap && node scripts/side-by-side.mjs node && node scripts/merge-across.mjs && node scripts/rust-direct-playwright-integration.mjs && node scripts/rust-custom-browser-playwright-integration.mjs && node scripts/cross-origin-requests.mjs && node scripts/leaving-page.mjs && node scripts/background-by-time.mjs && node scripts/python-server-playwright.mjs && node scripts/ruby-server-playwright.mjs && node scripts/go-server-playwright.mjs && node scripts/jvm-server-playwright.mjs && node scripts/docker-server-playwright.mjs && node scripts/rust-classic-browser-integration.mjs && node scripts/rust-top-packages-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",
87
87
  "test:platform": "cargo test --workspace && cargo build -p supercov && node scripts/rust-process-supervision.mjs && node scripts/workspace-crash-integration.mjs",
88
88
  "test:native-package": "cargo build --release -p supercov && node scripts/native-package-integration.mjs && node scripts/native-release-set-integration.mjs",
89
89
  "test:pypi-wheel": "node scripts/pypi-wheel-integration.mjs",
@@ -120,14 +120,14 @@
120
120
  "test:windows-tls": "node scripts/windows-tls-test.mjs"
121
121
  },
122
122
  "optionalDependencies": {
123
- "@supercov/cli-darwin-arm64": "4.9.0",
124
- "@supercov/cli-darwin-x64": "4.9.0",
125
- "@supercov/cli-linux-arm64-gnu": "4.9.0",
126
- "@supercov/cli-linux-arm64-musl": "4.9.0",
127
- "@supercov/cli-linux-x64-gnu": "4.9.0",
128
- "@supercov/cli-linux-x64-musl": "4.9.0",
129
- "@supercov/cli-win32-arm64": "4.9.0",
130
- "@supercov/cli-win32-x64": "4.9.0"
123
+ "@supercov/cli-darwin-arm64": "4.11.0",
124
+ "@supercov/cli-darwin-x64": "4.11.0",
125
+ "@supercov/cli-linux-arm64-gnu": "4.11.0",
126
+ "@supercov/cli-linux-arm64-musl": "4.11.0",
127
+ "@supercov/cli-linux-x64-gnu": "4.11.0",
128
+ "@supercov/cli-linux-x64-musl": "4.11.0",
129
+ "@supercov/cli-win32-arm64": "4.11.0",
130
+ "@supercov/cli-win32-x64": "4.11.0"
131
131
  },
132
132
  "peerDependencies": {
133
133
  "@playwright/test": ">=1.55.0",
@@ -2,6 +2,22 @@ import { appendFileSync, existsSync, mkdirSync, openSync, closeSync, fsyncSync,
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { Buffer } from "node:buffer";
5
+
6
+ // In a container Supercov's `docker` launcher mounts the run's directory
7
+ // into, its user (root) owns what it creates there; a directory made with
8
+ // the usual mask would keep the run, outside, from removing what goes in it.
9
+ function makeDirectory(directory) {
10
+ if (process.env.SUPERCOV_CONTAINER !== "1")
11
+ return mkdirSync(directory, { recursive: true });
12
+ const previous = process.umask(0);
13
+ try {
14
+ return mkdirSync(directory, { recursive: true, mode: 0o777 });
15
+ }
16
+ finally {
17
+ process.umask(previous);
18
+ }
19
+ }
20
+
5
21
  function fsyncDirectory(path) {
6
22
  try {
7
23
  const directory = openSync(path, "r");
@@ -18,7 +34,7 @@ function fsyncDirectory(path) {
18
34
  }
19
35
  /** Write a complete sibling file and atomically replace the destination. */
20
36
  export function atomicWriteFileSync(path, data, options) {
21
- mkdirSync(dirname(path), { recursive: true });
37
+ makeDirectory(dirname(path));
22
38
  const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
23
39
  let descriptor;
24
40
  try {
@@ -41,7 +57,7 @@ export function atomicWriteFileSync(path, data, options) {
41
57
  }
42
58
  /** Append one recoverable JSONL record and make the completed line durable. */
43
59
  export function appendJsonLineDurableSync(path, data) {
44
- mkdirSync(dirname(path), { recursive: true });
60
+ makeDirectory(dirname(path));
45
61
  const existed = existsSync(path);
46
62
  const descriptor = openSync(path, "a", 0o600);
47
63
  try {
@@ -56,12 +72,12 @@ export function appendJsonLineDurableSync(path, data) {
56
72
  }
57
73
  /** Append a complete local record; process exit closes it before publication. */
58
74
  export function appendJsonLineSync(path, data) {
59
- mkdirSync(dirname(path), { recursive: true });
75
+ makeDirectory(dirname(path));
60
76
  appendFileSync(path, data.endsWith("\n") ? data : `${data}\n`, { mode: 0o600 });
61
77
  }
62
78
  /** Atomically publish a fully prepared file or directory and persist its entry. */
63
79
  export function atomicRenameSync(source, destination) {
64
- mkdirSync(dirname(destination), { recursive: true });
80
+ makeDirectory(dirname(destination));
65
81
  renameSync(source, destination);
66
82
  fsyncDirectory(dirname(destination));
67
83
  }
@@ -19,21 +19,28 @@ else {
19
19
  // <workspace>/.supercov/node_modules/bootstrap.cjs
20
20
  const root = path.resolve(__dirname, "..", "..");
21
21
  const settings = JSON.parse(fs.readFileSync(path.join(__dirname, "run.json"), "utf8"));
22
- const moved = (value) => typeof value === "string" && settings.root !== root
23
- ? value.split(settings.root).join(root)
22
+ // An image built from the workspace holds a copy of it, but its
23
+ // evidence belongs where the run reads it: when the run's own
24
+ // directory is mounted here at its path (Supercov's `docker` launcher
25
+ // mounts it), the host's paths are kept, and a copy that goes away
26
+ // with its container is not written to.
27
+ const hostVisible = settings.root !== root && fs.existsSync(path.join(settings.root, ".supercov"));
28
+ const here = hostVisible ? settings.root : root;
29
+ const moved = (value) => typeof value === "string" && settings.root !== here
30
+ ? value.split(settings.root).join(here)
24
31
  : value;
25
32
  // Settings carried over from the host name the host's paths, which do
26
33
  // not exist here; replace them all rather than keep a mixture.
27
- const foreign = process.env.SUPERCOV_PROJECT_ROOT !== root;
34
+ const foreign = process.env.SUPERCOV_PROJECT_ROOT !== here;
28
35
  for (const [name, value] of Object.entries(settings.environment)) {
29
36
  if (foreign || process.env[name] === undefined)
30
37
  process.env[name] = moved(value);
31
38
  }
32
- process.env.SUPERCOV_PROJECT_ROOT = root;
39
+ process.env.SUPERCOV_PROJECT_ROOT = here;
33
40
  process.env.SUPERCOV_EXECUTION_LOG_SHARD ??= `bootstrap-${process.pid}-${Date.now()}`;
34
41
  require("./runtime.mjs");
35
42
  // Counted in the run's summary: coverage from here belongs to no test.
36
- require("./launchSupervisor.mjs").recordExecution({ event: "guest-process", guestRoot: root });
43
+ require("./launchSupervisor.mjs").recordExecution({ event: "guest-process", guestRoot: here });
37
44
  }
38
45
  catch {
39
46
  // Without its settings or its runtime nothing can be recorded here,
@@ -4,6 +4,22 @@ import { appendFileSync, mkdirSync, statSync } from "node:fs";
4
4
  import Module, { syncBuiltinESMExports } from "node:module";
5
5
  import { dirname, isAbsolute, relative, resolve } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
+
8
+ // In a container Supercov's `docker` launcher mounts the run's directory
9
+ // into, its user (root) owns what it creates there; a directory made with
10
+ // the usual mask would keep the run, outside, from removing what goes in it.
11
+ function makeDirectory(directory) {
12
+ if (process.env.SUPERCOV_CONTAINER !== "1")
13
+ return mkdirSync(directory, { recursive: true });
14
+ const previous = process.umask(0);
15
+ try {
16
+ return mkdirSync(directory, { recursive: true, mode: 0o777 });
17
+ }
18
+ finally {
19
+ process.umask(previous);
20
+ }
21
+ }
22
+
7
23
  const HOST_PATH_KEYS = ["hostPath", "source", "src", "localPath"];
8
24
  const GUEST_PATH_KEYS = [
9
25
  "guestPath",
@@ -83,7 +99,7 @@ function record(value) {
83
99
  ppid: process.ppid,
84
100
  ...value,
85
101
  })}\n`;
86
- mkdirSync(dirname(path), { recursive: true });
102
+ makeDirectory(dirname(path));
87
103
  appendFileSync(path, line);
88
104
  executionLog = { token: executionLog.token, path, size: executionLog.size + Buffer.byteLength(line) };
89
105
  }
@@ -216,6 +216,9 @@ class CoveragePhaseController {
216
216
  // fixture override tears down before the collector's own fixture, and a
217
217
  // closed page cannot be evaluated, so the evidence is taken on the way out.
218
218
  earlySnapshots = [];
219
+ // Evidence documents gave as they left (`__supercovLeaving`), merged with
220
+ // the rest when there is any.
221
+ leftSnapshots = [];
219
222
  snapshottedPages = new WeakSet();
220
223
  snapshottedWorkers = new WeakSet();
221
224
  // Workers that did not answer an evaluation in time. They are not waited
@@ -279,8 +282,10 @@ class CoveragePhaseController {
279
282
  if (snapshot)
280
283
  snapshots.push(snapshot);
281
284
  }
282
- this.runtimeSnapshots = snapshots;
283
- return snapshots;
285
+ this.runtimeSnapshots = this.leftSnapshots.length > 0
286
+ ? [mergeSnapshots([...this.leftSnapshots, ...snapshots])]
287
+ : snapshots;
288
+ return this.runtimeSnapshots;
284
289
  }
285
290
  /**
286
291
  * A worker's evidence, or nothing when it cannot be read. A worker that
@@ -291,20 +296,15 @@ class CoveragePhaseController {
291
296
  if (this.workersWithoutRuntime.has(worker)) {
292
297
  // Asked once more, without waiting: it may have loaded an
293
298
  // instrumented script since it was registered.
294
- const answer = await bounded(worker
295
- .evaluate(() => globalThis.__SUPERCOV_COVERAGE_SNAPSHOT__?.())
296
- .catch(() => undefined), WORKER_ACTIVATION_WAIT_MS);
299
+ const answer = await bounded(readSnapshot(worker).catch(() => undefined), WORKER_ACTIVATION_WAIT_MS);
297
300
  if (answer && answer !== WORKER_TIMED_OUT) {
298
301
  this.snapshottedWorkers.add(worker);
299
302
  return answer;
300
303
  }
301
304
  return undefined;
302
305
  }
303
- const answer = await bounded(worker
304
- .evaluate(() => {
305
- const getSnapshot = globalThis.__SUPERCOV_COVERAGE_SNAPSHOT__;
306
- return getSnapshot?.() ?? { decisions: [], hits: [], events: [] };
307
- })
306
+ const answer = await bounded(readSnapshot(worker)
307
+ .then((snapshot) => snapshot ?? { decisions: [], hits: [], events: [] })
308
308
  .catch(() => undefined), WORKER_SNAPSHOT_WAIT_MS);
309
309
  if (answer === WORKER_TIMED_OUT) {
310
310
  this.unresponsiveWorkers.add(worker);
@@ -335,11 +335,11 @@ class CoveragePhaseController {
335
335
  .evaluate(({ token, url }) => {
336
336
  const entries = globalThis.__SUPERCOV_WORKER_EVIDENCE__?.() ?? [];
337
337
  const byToken = token ? entries.find((entry) => entry.token === token) : undefined;
338
- if (byToken)
339
- return byToken;
340
338
  const byUrl = entries.filter((entry) => entry.url === url);
341
- return byUrl.length === 1 ? byUrl[0] : undefined;
339
+ const found = byToken ?? (byUrl.length === 1 ? byUrl[0] : undefined);
340
+ return found ? JSON.stringify(found) : undefined;
342
341
  }, { token, url })
342
+ .then((text) => (typeof text === "string" ? JSON.parse(text) : undefined))
343
343
  .catch(() => undefined), WORKER_ACTIVATION_WAIT_MS);
344
344
  if (pushed && pushed !== WORKER_TIMED_OUT)
345
345
  return pushed;
@@ -349,11 +349,8 @@ class CoveragePhaseController {
349
349
  async snapshotPage(page) {
350
350
  const snapshots = [];
351
351
  for (const frame of page.frames()) {
352
- snapshots.push(await frame
353
- .evaluate(() => {
354
- const getSnapshot = globalThis.__SUPERCOV_COVERAGE_SNAPSHOT__;
355
- return getSnapshot?.() ?? { decisions: [], hits: [], events: [] };
356
- })
352
+ snapshots.push(await readSnapshot(frame)
353
+ .then((snapshot) => snapshot ?? { decisions: [], hits: [], events: [] })
357
354
  .catch(() => ({
358
355
  decisions: [],
359
356
  hits: [],
@@ -432,6 +429,20 @@ class CoveragePhaseController {
432
429
  // learned its phase only from the next action, after it had
433
430
  // loaded, and everything it ran while loading was placed by time.
434
431
  await cdp.send("Page.enable").catch(() => undefined);
432
+ // A document that leaves hands its evidence over here: the
433
+ // next one reads what it stored only on the same origin.
434
+ cdp.on("Runtime.bindingCalled", ({ name, payload }) => {
435
+ if (name !== LEAVING_BINDING || this.runtimeSnapshots)
436
+ return;
437
+ try {
438
+ this.leftSnapshots.push(JSON.parse(payload));
439
+ }
440
+ catch {
441
+ // Not evidence.
442
+ }
443
+ });
444
+ await cdp.send("Runtime.enable").catch(() => undefined);
445
+ await cdp.send("Runtime.addBinding", { name: LEAVING_BINDING }).catch(() => undefined);
435
446
  this.cdpSessions.set(page, cdp);
436
447
  }
437
448
  const phaseId = this.requestPhaseId();
@@ -883,6 +894,63 @@ class CoveragePhaseController {
883
894
  await this.scriptUpdate.catch(() => undefined);
884
895
  }
885
896
  }
897
+ /**
898
+ * A page's or worker's evidence. It holds an event for every hit of every
899
+ * phase, tens of thousands of objects on a large frontend, and Playwright
900
+ * returns an object value by value: reading it took half a second a page at
901
+ * the end of every test (20,000 events: 470 ms, against 34 ms as one JSON
902
+ * string), and doubled a 76-test suite's time.
903
+ */
904
+ const LEAVING_BINDING = "__supercovLeaving";
905
+ /**
906
+ * One snapshot holding everything in `snapshots`, each observation once: a
907
+ * document that left gave its evidence, and the next one on its origin
908
+ * restored the same evidence and added to it.
909
+ */
910
+ function mergeSnapshots(snapshots) {
911
+ const hits = new Set();
912
+ const events = new Map();
913
+ const decisions = new Map();
914
+ const logicals = new Map();
915
+ const vectorsOf = (map, key, initial) => {
916
+ let entry = map.get(key);
917
+ if (!entry) {
918
+ entry = { ...initial, seen: new Map() };
919
+ map.set(key, entry);
920
+ }
921
+ return entry;
922
+ };
923
+ for (const snapshot of snapshots) {
924
+ for (const id of snapshot?.hits ?? [])
925
+ hits.add(id);
926
+ for (const event of snapshot?.events ?? [])
927
+ events.set(JSON.stringify(event), event);
928
+ for (const decision of snapshot?.decisions ?? []) {
929
+ const entry = vectorsOf(decisions, decision.meta?.id, { meta: decision.meta });
930
+ for (const vector of decision.vectors ?? [])
931
+ entry.seen.set(JSON.stringify(vector), vector);
932
+ }
933
+ for (const logical of snapshot?.logicals ?? []) {
934
+ const entry = vectorsOf(logicals, logical.id, { id: logical.id });
935
+ for (const vector of logical.vectors ?? [])
936
+ entry.seen.set(JSON.stringify(vector), vector);
937
+ }
938
+ }
939
+ return {
940
+ decisions: [...decisions.values()].map(({ seen, meta }) => ({ meta, vectors: [...seen.values()] })),
941
+ hits: [...hits],
942
+ events: [...events.values()],
943
+ logicals: [...logicals.values()].map(({ seen, id }) => ({ id, vectors: [...seen.values()] })),
944
+ };
945
+ }
946
+ function readSnapshot(target) {
947
+ return target
948
+ .evaluate(() => {
949
+ const getSnapshot = globalThis.__SUPERCOV_COVERAGE_SNAPSHOT__;
950
+ return getSnapshot ? JSON.stringify(getSnapshot()) : undefined;
951
+ })
952
+ .then((text) => (typeof text === "string" ? JSON.parse(text) : undefined));
953
+ }
886
954
  let activeController;
887
955
  let bridgedAssertionDepth = 0;
888
956
  const controllers = new Map();
@@ -25,9 +25,6 @@ var __spreadProps = (a, b) => __spreadValues(a, b);
25
25
  // `globalThis.Buffer = undefined` to check it copes -- and the runtime runs
26
26
  // inside those tests; it must not fail where the application does not.
27
27
  var capturedBuffer = typeof Buffer === "undefined" ? void 0 : Buffer;
28
- // Likewise the timer: a test that installs fake timers would otherwise hold
29
- // the browser's evidence back until it advanced its clock.
30
- var capturedSetTimeout = setTimeout;
31
28
 
32
29
  // dist/transport.js
33
30
  var COVERAGE_SCOPE_HEADER = "x-supercov-scope";
@@ -419,7 +416,6 @@ function pushWorkerEvidence() {
419
416
  }
420
417
  runtimeGlobal.__SUPERCOV_WORKER_EVIDENCE__ = () => Array.from(received, ([token, pushed]) => ({ token, url: pushed.url, current: pushed.current, snapshot: pushed.snapshot }));
421
418
  })();
422
- var persistBrowserScheduled = false;
423
419
  var persistBrowserListenersInstalled = false;
424
420
  function persistBrowserNow() {
425
421
  if (!isBrowser)
@@ -429,31 +425,39 @@ function persistBrowserNow() {
429
425
  } catch (e) {
430
426
  }
431
427
  }
428
+ // The Playwright collector's binding takes the evidence of a document that
429
+ // is about to leave. localStorage gives it to the next document only on the
430
+ // same origin: a click that sent the page to another one (an admin's
431
+ // masquerade link, a sign-in redirect) lost every hit it made. Called from
432
+ // `pagehide` the binding never reached the collector; from `beforeunload`
433
+ // it does.
434
+ function handOverBrowser() {
435
+ try {
436
+ if (typeof runtimeGlobal.__supercovLeaving === "function")
437
+ runtimeGlobal.__supercovLeaving(JSON.stringify(coverageSnapshot()));
438
+ } catch (e) {
439
+ }
440
+ }
432
441
  function persistBrowser() {
433
442
  if (!isBrowser)
434
443
  return;
435
- // Persistence exists so evidence survives navigation, not to mirror every
436
- // event: serializing the whole snapshot per event is quadratic in a
437
- // render burst. Coalesce to one write per macrotask and flush when the
438
- // page is actually leaving.
439
- if (!persistBrowserListenersInstalled) {
440
- persistBrowserListenersInstalled = true;
441
- try {
442
- addEventListener("pagehide", persistBrowserNow);
443
- addEventListener("visibilitychange", () => {
444
- if (typeof document !== "undefined" && document.visibilityState === "hidden")
445
- persistBrowserNow();
446
- });
447
- } catch (e) {
448
- }
449
- }
450
- if (persistBrowserScheduled)
444
+ // Persistence exists so evidence survives navigation, so it is written
445
+ // when the page leaves. Written after every macrotask that recorded
446
+ // anything, the whole snapshot was serialized again and again: a large
447
+ // instrumented frontend doubled a Playwright suite's time. A page the
448
+ // test closes is read by the collector before it closes.
449
+ if (persistBrowserListenersInstalled)
451
450
  return;
452
- persistBrowserScheduled = true;
453
- capturedSetTimeout(() => {
454
- persistBrowserScheduled = false;
455
- persistBrowserNow();
456
- }, 0);
451
+ persistBrowserListenersInstalled = true;
452
+ try {
453
+ addEventListener("beforeunload", handOverBrowser);
454
+ addEventListener("pagehide", persistBrowserNow);
455
+ addEventListener("visibilitychange", () => {
456
+ if (typeof document !== "undefined" && document.visibilityState === "hidden")
457
+ persistBrowserNow();
458
+ });
459
+ } catch (e) {
460
+ }
457
461
  }
458
462
  function attemptKey(scope) {
459
463
  return `${scope.runId}\0${scope.workerId}\0${scope.attemptId}`;
@@ -517,7 +521,7 @@ function flushBufferedServerEvidence(scope) {
517
521
  if (!fs || buffered.records.size === 0)
518
522
  return void 0;
519
523
  try {
520
- fs.mkdirSync(buffered.directory, { recursive: true });
524
+ makeEvidenceDirectory(fs, buffered.directory);
521
525
  fs.appendFileSync(buffered.path, [...buffered.records.values()].map((record) => JSON.stringify(record)).join("\n") + "\n");
522
526
  return buffered.path;
523
527
  } catch (e) {
@@ -607,11 +611,32 @@ function flushBackgroundAppends(fs) {
607
611
  }
608
612
  }
609
613
  }
614
+ // In a container Supercov's `docker` launcher mounts the run's directory
615
+ // into, its user (root) owns what it creates there, and the run, outside,
616
+ // could not remove what went in it. There each directory made is opened to
617
+ // all.
618
+ function makeEvidenceDirectory(fs, directory) {
619
+ const first = fs.mkdirSync(directory, { recursive: true });
620
+ if (!first || typeof process === "undefined" || !process.env || process.env.SUPERCOV_CONTAINER !== "1")
621
+ return;
622
+ let current = first;
623
+ const opened = [current];
624
+ for (const part of directory.slice(first.length).split(/[\\/]/).filter(Boolean)) {
625
+ current = `${current}/${part}`;
626
+ opened.push(current);
627
+ }
628
+ for (const made of opened) {
629
+ try {
630
+ fs.chmodSync(made, 0o777);
631
+ } catch (e) {
632
+ }
633
+ }
634
+ }
610
635
  function writeBackgroundLines(fs, runId, payload) {
611
636
  var _a8;
612
637
  const directory = backgroundEvidenceDirectory(runId);
613
638
  if (!state.createdEvidenceDirectories.has(directory)) {
614
- fs.mkdirSync(directory, { recursive: true });
639
+ makeEvidenceDirectory(fs, directory);
615
640
  state.createdEvidenceDirectories.add(directory);
616
641
  }
617
642
  let path = state.backgroundWriters.get(runId);
@@ -741,7 +766,7 @@ function flushServerAppends() {
741
766
  if (!fs)
742
767
  throw new Error("node:fs is unavailable");
743
768
  if (!state.createdEvidenceDirectories.has(entry.directory)) {
744
- fs.mkdirSync(entry.directory, { recursive: true });
769
+ makeEvidenceDirectory(fs, entry.directory);
745
770
  state.createdEvidenceDirectories.add(entry.directory);
746
771
  }
747
772
  fs.appendFileSync(path, entry.lines.join(""));