supercov 4.6.0 → 4.8.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.
package/docs/cli.md CHANGED
@@ -121,6 +121,18 @@ npx supercov -- cargo nextest run
121
121
  Use the complete command you rely on before merging or deploying. A coverage run
122
122
  preserves the wrapped command's exit status, so it can remain a CI gate.
123
123
 
124
+ A script that queries the run it just recorded can have Supercov write the
125
+ run's id to a file. `latest` is the newest run, which is another one when two
126
+ commands record runs side by side:
127
+
128
+ ```sh supercov-example
129
+ SUPERCOV_RUN_ID_FILE=unit.id npx supercov -- <test command>
130
+ npx supercov runs <run-id> check --min-lines 80 # the id in unit.id
131
+ ```
132
+
133
+ The file is removed when the run starts and written when it is recorded, so a
134
+ run that is not recorded leaves no id behind.
135
+
124
136
  ## List and select runs
125
137
 
126
138
  ```sh supercov
@@ -511,9 +523,10 @@ pass in the run is listed regardless.
511
523
  per line, for a runner's filter. A Vitest, Playwright or node:test test inside
512
524
  `describe` blocks is printed as the blocks and the test joined by spaces
513
525
  (`small charges`), which is what `vitest -t`, `--grep` and
514
- `--test-name-pattern` match. A helper beside the tests that changed affects
515
- the tests whose files import it (a helper that only gained functions affects
516
- none), and every test where Supercov cannot read the imports. A dependency,
526
+ `--test-name-pattern` match. A helper beside the tests, or source outside what is
527
+ measured, that changed affects the tests whose files import it (a JavaScript or
528
+ TypeScript helper that only gained functions affects none), and every test
529
+ where Supercov cannot read the imports. A dependency,
517
530
  lockfile, configuration or toolchain change affects every test and is reported
518
531
  as such; a test runner's settings affect only the runs that used that runner.
519
532
  A source file
@@ -615,12 +628,15 @@ what that run's tests did with it:
615
628
 
616
629
  - Another run's test file: it was not in this run. A file of the run's own
617
630
  tests: what it recorded is of other tests, and the run is left out.
618
- - A helper, fixture or support file: left out of a run only when that run's
619
- test files import it, directly or through other files. For JavaScript and
620
- TypeScript tests Supercov reads the imports; a computed import path, or tests
621
- in another language, count every helper as loaded. A helper that only gained
622
- functions, which code written before cannot have called, leaves every run
623
- in.
631
+ - A file the run measured: left out, always.
632
+ - A helper, fixture or support file, or source outside what is measured (a
633
+ simulator kept out of `SUPERCOV_SOURCE_ROOTS`): left out of a run only when
634
+ that run's test files import it, directly or through other files. Supercov
635
+ reads the imports of JavaScript, TypeScript and Python tests, with pytest's
636
+ `conftest.py` files and `pytest_plugins`; a computed import, a change to
637
+ `sys.path`, or tests in another language count every helper as loaded. A
638
+ JavaScript or TypeScript helper that only gained functions, which code
639
+ written before cannot have called, leaves every run in.
624
640
  - A test runner's settings: `playwright.config.*`, `vitest.config.*`,
625
641
  `jest.config.*` and `ava.config.*` belong to their runner, and a run that
626
642
  did not use that runner keeps. Of Playwright's settings, only those that
@@ -648,9 +664,9 @@ same, and a run whose own test files changed is still left out. What was
648
664
  accepted is listed in `merged across:` as accepted.
649
665
 
650
666
  `assertions assess` assesses a merged run across every suite merged into it,
651
- reusing the answers saved when its parts were assessed. `tests affected` is
652
- asked of the runs that were merged, which hold the record of what each test
653
- ran.
667
+ reusing the answers saved when its parts were assessed. `tests affected` of a
668
+ merged run is answered from the runs it was merged from, which hold the record
669
+ of what each test ran; a test in several of them is affected where any says so.
654
670
 
655
671
  ## Clean local data
656
672
 
@@ -698,6 +714,7 @@ terminal or offline environment after the package has been downloaded.
698
714
  | `SUPERCOV_SOURCE_ROOTS` | Comma-separated directories or files that hold your own code, in any language; everything else is left out |
699
715
  | `SUPERCOV_TEST_KIND` | Label the wrapped command as a test level such as `unit` or `e2e` |
700
716
  | `SUPERCOV_SOURCE_TOOLS` | Comma-separated names of tools the test command runs that read source as text, such as a linter or a spell checker, besides Biome, oxlint, dprint, cspell and knip. Each is run on your source as you wrote it instead of on the instrumented copy |
717
+ | `SUPERCOV_RUN_ID_FILE` | A file to write the recorded run's id to, for a script to query that run rather than `latest` |
701
718
  | `SUPERCOV_KEEP_WORKSPACE` | Set to `1` to leave the instrumented copy of the project in `.supercov/workspaces/` after the run, to inspect what the command ran |
702
719
 
703
720
  Examples:
@@ -96,7 +96,16 @@ Both are useful:
96
96
 
97
97
  Code that runs while no test is running counts in the run's total and in no
98
98
  test's: a server starting before the first test, modules loading, work between
99
- tests. When a run has any, the summary's `By test kind` table ends with a
99
+ tests.
100
+
101
+ A request to your app server that names no test -- a webhook from a mock
102
+ Stripe server Supercov does not measure -- is credited to the test running when
103
+ the server handled it, if that test was the only one running then. A mock that
104
+ runs under Supercov passes the test's identity on in its own requests, and its
105
+ webhook is credited exactly. The run's
106
+ `Attribution` line counts those tests as `with server work credited by time`,
107
+ and each one's `runs <id> test` page says so. Work done while tests ran at
108
+ once, or while none ran, stays no test's. When a run has any, the summary's `By test kind` table ends with a
100
109
  `no test` row for what only that covered, which is why a run with one kind of
101
110
  test can show a total above that kind's own:
102
111
 
@@ -120,6 +120,20 @@ Each completed run stores compressed evidence and metadata under
120
120
  `.supercov/runs/<run-id>/`. Query views are derived from that evidence rather
121
121
  than stored as a full report for every filter.
122
122
 
123
+ Most of a large run is its query index, `query-index.v1.bin`: on a suite of
124
+ about 400 tests with per-test evidence, 160 MB of a 216 MB run, beside 58 MB of
125
+ evidence. The index is a cache. Supercov builds it again from the evidence the
126
+ first time the run is queried without it, so a CI job that keeps runs as an
127
+ artifact can leave it out:
128
+
129
+ ```sh supercov-example
130
+ tar czf supercov-runs.tgz --exclude 'query-index.v1.bin' .supercov/runs
131
+ ```
132
+
133
+ A merged run holds a copy of each run's evidence and an index of its own, so
134
+ merging adds about as much again. To hand a run to a reviewer, the HTML report
135
+ (`npx supercov report`) is usually the smaller artifact.
136
+
123
137
  The isolated workspace may be larger because it can contain an instrumented
124
138
  build cache. Supercov does not delete history in the background.
125
139
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supercov",
3
- "version": "4.6.0",
3
+ "version": "4.8.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/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/background-by-time.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.6.0",
124
- "@supercov/cli-darwin-x64": "4.6.0",
125
- "@supercov/cli-linux-arm64-gnu": "4.6.0",
126
- "@supercov/cli-linux-arm64-musl": "4.6.0",
127
- "@supercov/cli-linux-x64-gnu": "4.6.0",
128
- "@supercov/cli-linux-x64-musl": "4.6.0",
129
- "@supercov/cli-win32-arm64": "4.6.0",
130
- "@supercov/cli-win32-x64": "4.6.0"
123
+ "@supercov/cli-darwin-arm64": "4.8.0",
124
+ "@supercov/cli-darwin-x64": "4.8.0",
125
+ "@supercov/cli-linux-arm64-gnu": "4.8.0",
126
+ "@supercov/cli-linux-arm64-musl": "4.8.0",
127
+ "@supercov/cli-linux-x64-gnu": "4.8.0",
128
+ "@supercov/cli-linux-x64-musl": "4.8.0",
129
+ "@supercov/cli-win32-arm64": "4.8.0",
130
+ "@supercov/cli-win32-x64": "4.8.0"
131
131
  },
132
132
  "peerDependencies": {
133
133
  "@playwright/test": ">=1.55.0",
@@ -18,7 +18,7 @@ import * as standardPlaywright from "@playwright/test";
18
18
  import * as coverageRuntime from "./runtime.mjs";
19
19
  import { inferTestProvenance } from "./provenance.mjs";
20
20
  import { appendJsonLineDurableSync, appendJsonLineSync } from "./atomic.mjs";
21
- import { COVERAGE_PHASE_HEADER, COVERAGE_PHASE_COOKIE, COVERAGE_SCOPE_COOKIE, COVERAGE_SCOPE_HEADER, COVERAGE_CARRIER_ENV, encodeCoverageCarrier, encodeCoverageScope, serverEvidenceDirectory, serverEvidencePath, } from "./transport.mjs";
21
+ import { COVERAGE_PHASE_HEADER, COVERAGE_PHASE_COOKIE, COVERAGE_PHASE_SITE_COOKIE, COVERAGE_SCOPE_COOKIE, COVERAGE_SCOPE_HEADER, COVERAGE_SCOPE_SITE_COOKIE, COVERAGE_CARRIER_ENV, encodeCoverageCarrier, encodeCoverageScope, serverEvidenceDirectory, serverEvidencePath, } from "./transport.mjs";
22
22
  export * from "@playwright/test";
23
23
  const generatedTargetModule = "__SUPERCOV_PLAYWRIGHT_MODULE__";
24
24
  const targetModule = process.env["SUPERCOV_PLAYWRIGHT_MODULE"] ??
@@ -236,9 +236,21 @@ class CoveragePhaseController {
236
236
  // Parameter properties are stateful despite the base ESLint rule treating
237
237
  // this as an empty constructor.
238
238
  // eslint-disable-next-line no-useless-constructor
239
- constructor(scope, configuredHeaders = {}) {
239
+ constructor(scope, configuredHeaders = {}, baseURL) {
240
240
  this.scope = scope;
241
241
  this.configuredHeaders = configuredHeaders;
242
+ // Where the app under test is served: the project's base URL, and the
243
+ // local hosts a web server listens on. A cookie is kept per host, for
244
+ // every port of it.
245
+ const hosts = ["http://localhost/", "http://127.0.0.1/"];
246
+ try {
247
+ if (baseURL)
248
+ hosts.unshift(new URL("/", baseURL).href);
249
+ }
250
+ catch {
251
+ // A base URL Playwright itself would refuse.
252
+ }
253
+ this.appHosts = [...new Set(hosts)];
242
254
  }
243
255
  allPages() {
244
256
  return [...this.pages];
@@ -446,17 +458,34 @@ class CoveragePhaseController {
446
458
  // own attempt, so the newest wins; the fetch patch is installed once
447
459
  // and reads the attempt at call time, so a stale copy can never pin an
448
460
  // old scope onto a request the way nested wrappers would.
449
- await context.addInitScript(({ attemptId, scopeHeader, scopeValue, scopeCookie }) => {
461
+ await context.addInitScript(({ attemptId, scopeHeader, scopeValue, scopeCookie, scopeSiteCookie }) => {
450
462
  const coverageGlobal = globalThis;
451
463
  coverageGlobal.__SUPERCOV_ATTEMPT__ = { attemptId, scopeValue };
452
464
  coverageGlobal.__SUPERCOV_MCDC_TEST_ID__ = attemptId;
453
465
  try {
454
466
  document.cookie = `${scopeCookie}=${encodeURIComponent(scopeValue)}; Path=/; SameSite=Lax`;
467
+ // And for this document's frames on another of the app's
468
+ // hosts, where crossing sites is allowed.
469
+ if (globalThis.isSecureContext)
470
+ document.cookie = `${scopeSiteCookie}=${encodeURIComponent(scopeValue)}; Path=/; SameSite=None; Secure`;
455
471
  if (!coverageGlobal.__SUPERCOV_BROWSER_FETCH_PATCHED__) {
456
472
  const originalFetch = globalThis.fetch?.bind(globalThis);
457
473
  if (originalFetch) {
458
474
  coverageGlobal.__SUPERCOV_BROWSER_FETCH_PATCHED__ = true;
459
475
  globalThis.fetch = ((input, init) => {
476
+ // Only the page's own origin hears the scope. A
477
+ // header on a request to another origin makes the
478
+ // browser ask that origin first, and one that
479
+ // allows only the headers it uses refuses: the
480
+ // page's fetch failed under measurement alone.
481
+ try {
482
+ const target = new URL(input instanceof Request ? input.url : String(input), location.href);
483
+ if (target.origin !== location.origin)
484
+ return originalFetch(input, init);
485
+ }
486
+ catch {
487
+ return originalFetch(input, init);
488
+ }
460
489
  const headers = new Headers(init?.headers ??
461
490
  (input instanceof Request ? input.headers : undefined));
462
491
  headers.set(scopeHeader, coverageGlobal.__SUPERCOV_ATTEMPT__?.scopeValue ?? scopeValue);
@@ -476,6 +505,7 @@ class CoveragePhaseController {
476
505
  scopeHeader: COVERAGE_SCOPE_HEADER,
477
506
  scopeValue: encodeCoverageScope(this.scope),
478
507
  scopeCookie: COVERAGE_SCOPE_COOKIE,
508
+ scopeSiteCookie: COVERAGE_SCOPE_SITE_COOKIE,
479
509
  }).catch(() => undefined);
480
510
  const register = (page) => {
481
511
  const pending = this.registerPage(page).finally(() => this.pendingRegistrations.delete(pending));
@@ -513,6 +543,16 @@ class CoveragePhaseController {
513
543
  if (!originalFetch)
514
544
  return answer;
515
545
  globalThis.fetch = ((input, init) => {
546
+ // As in a page: another origin would be asked first, and may
547
+ // refuse a header it does not expect.
548
+ try {
549
+ const target = new URL(input instanceof Request ? input.url : String(input), globalThis.location.href);
550
+ if (target.origin !== globalThis.location.origin)
551
+ return originalFetch(input, init);
552
+ }
553
+ catch {
554
+ return originalFetch(input, init);
555
+ }
516
556
  const headers = new Headers(init?.headers ??
517
557
  (input instanceof Request ? input.headers : undefined));
518
558
  headers.set(scopeHeader, scopeValue);
@@ -705,13 +745,10 @@ class CoveragePhaseController {
705
745
  const options = scoped[0] && typeof scoped[0] === "object"
706
746
  ? scoped[0]
707
747
  : {};
708
- scoped[0] = {
709
- ...options,
710
- extraHTTPHeaders: {
711
- ...(options.extraHTTPHeaders ?? {}),
712
- ...(activeCoverageHeaders() ?? {}),
713
- },
714
- };
748
+ // The scope reaches the app in cookies once the context is
749
+ // registered (`updateContextHeaders`); headers on the context would
750
+ // go to every origin the page fetches from.
751
+ scoped[0] = { ...options };
715
752
  return scoped;
716
753
  }
717
754
  /**
@@ -734,7 +771,7 @@ class CoveragePhaseController {
734
771
  }
735
772
  async activateFrames(phaseId) {
736
773
  await timed("activate.frameEvaluate", () => Promise.all([...this.pages].flatMap((page) => page.frames()).map((frame) => frame
737
- .evaluate(({ id, attemptId, storageKey, scopeCookie, scopeValue, phaseCookie }) => {
774
+ .evaluate(({ id, attemptId, storageKey, scopeCookie, scopeValue, phaseCookie, scopeSiteCookie, phaseSiteCookie }) => {
738
775
  globalThis.__SUPERCOV_PHASE_ID__ = id;
739
776
  const coverageGlobal = globalThis;
740
777
  // A document that loaded under an earlier test's attempt (a page
@@ -746,6 +783,10 @@ class CoveragePhaseController {
746
783
  localStorage.setItem(storageKey, id);
747
784
  document.cookie = `${scopeCookie}=${encodeURIComponent(scopeValue)}; Path=/; SameSite=Lax`;
748
785
  document.cookie = `${phaseCookie}=${encodeURIComponent(id)}; Path=/; SameSite=Lax`;
786
+ if (globalThis.isSecureContext) {
787
+ document.cookie = `${scopeSiteCookie}=${encodeURIComponent(scopeValue)}; Path=/; SameSite=None; Secure`;
788
+ document.cookie = `${phaseSiteCookie}=${encodeURIComponent(id)}; Path=/; SameSite=None; Secure`;
789
+ }
749
790
  }
750
791
  catch {
751
792
  // Sandboxed/cross-origin frames may not expose localStorage.
@@ -757,6 +798,8 @@ class CoveragePhaseController {
757
798
  scopeCookie: COVERAGE_SCOPE_COOKIE,
758
799
  scopeValue: encodeCoverageScope(this.scope),
759
800
  phaseCookie: COVERAGE_PHASE_COOKIE,
801
+ scopeSiteCookie: COVERAGE_SCOPE_SITE_COOKIE,
802
+ phaseSiteCookie: COVERAGE_PHASE_SITE_COOKIE,
760
803
  })
761
804
  .catch(() => undefined))));
762
805
  }
@@ -779,14 +822,43 @@ class CoveragePhaseController {
779
822
  this.unresponsiveWorkers.add(worker);
780
823
  })));
781
824
  }
825
+ /**
826
+ * Tell the app which test and phase its requests belong to, in cookies on
827
+ * the app's hosts. Headers set on the context went with every request the
828
+ * browser made, to any origin, and a header on a request to another
829
+ * origin makes the browser ask that origin first: one that allows only the
830
+ * headers it uses refused, and the page's request failed under
831
+ * measurement alone. A cookie never does. The page sends it to its own
832
+ * site from the first navigation on, and, on a secure origin, to frames
833
+ * of the app's other hosts as well.
834
+ */
782
835
  async updateContextHeaders(context, phaseId) {
783
- await context
784
- .setExtraHTTPHeaders({
785
- ...(this.contextConfiguredHeaders.get(context) ?? this.configuredHeaders),
786
- [COVERAGE_SCOPE_HEADER]: encodeCoverageScope(this.scope),
787
- ...(phaseId ? { [COVERAGE_PHASE_HEADER]: phaseId } : {}),
788
- })
789
- .catch(() => undefined);
836
+ const scope = encodeURIComponent(encodeCoverageScope(this.scope));
837
+ const phase = phaseId ? encodeURIComponent(phaseId) : undefined;
838
+ const pairs = (scopeName, phaseName) => [
839
+ [scopeName, scope],
840
+ ...(phase ? [[phaseName, phase]] : []),
841
+ ];
842
+ await Promise.all([
843
+ // The context's own headers are the owner's, unchanged.
844
+ context
845
+ .setExtraHTTPHeaders(this.contextConfiguredHeaders.get(context) ?? this.configuredHeaders)
846
+ .catch(() => undefined),
847
+ ...this.appHosts.map((url) => {
848
+ const host = new URL(url);
849
+ // For the app's own site: requests, navigations and sockets.
850
+ const cookies = pairs(COVERAGE_SCOPE_COOKIE, COVERAGE_PHASE_COOKIE).map(([name, value]) => ({ name, value, url, sameSite: "Lax" }));
851
+ // For its frames on another of its hosts: a cookie that
852
+ // crosses sites must be Secure, which needs https or a local
853
+ // host. Chromium keeps such a cookie only when it is given by
854
+ // domain; given by an http URL it is dropped without a word.
855
+ if (host.protocol === "https:" || ["localhost", "127.0.0.1"].includes(host.hostname)) {
856
+ cookies.push(...pairs(COVERAGE_SCOPE_SITE_COOKIE, COVERAGE_PHASE_SITE_COOKIE).map(([name, value]) => ({ name, value, domain: host.hostname, path: "/", sameSite: "None", secure: true })));
857
+ }
858
+ // One host refused leaves the others theirs.
859
+ return context.addCookies(cookies).catch(() => undefined);
860
+ }),
861
+ ]);
790
862
  }
791
863
  async activatePage(page, phaseId) {
792
864
  const cdp = this.cdpSessions.get(page);
@@ -1352,7 +1424,7 @@ const instrumentedFixtures = {
1352
1424
  async ({}, use, testInfo) => {
1353
1425
  const scope = executionScope(testInfo);
1354
1426
  const configuredHeaders = Object.fromEntries(Object.entries(testInfo.project.use.extraHTTPHeaders ?? {}).map(([name, value]) => [name, String(value)]));
1355
- const controller = new CoveragePhaseController(scope, configuredHeaders);
1427
+ const controller = new CoveragePhaseController(scope, configuredHeaders, testInfo.project.use.baseURL);
1356
1428
  controllers.set(scope.attemptId, controller);
1357
1429
  activeController = controller;
1358
1430
  directRuntime()?.activateCoverageScope(scope);
@@ -34,6 +34,8 @@ var COVERAGE_SCOPE_HEADER = "x-supercov-scope";
34
34
  var COVERAGE_PHASE_HEADER = "x-supercov-phase";
35
35
  var COVERAGE_SCOPE_COOKIE = "__supercov_scope";
36
36
  var COVERAGE_PHASE_COOKIE = "__supercov_phase";
37
+ var COVERAGE_SCOPE_SITE_COOKIE = "__supercov_scope_site";
38
+ var COVERAGE_PHASE_SITE_COOKIE = "__supercov_phase_site";
37
39
  var COVERAGE_CARRIER_ENV = "SUPERCOV_CONTEXT";
38
40
  var DEFAULT_SERVER_EVIDENCE_ROOT = "/tmp/supercov-server-evidence";
39
41
  function configuredServerEvidenceRoot() {
@@ -459,7 +461,16 @@ function attemptKey(scope) {
459
461
  function serverRecordKey(record) {
460
462
  var _a8;
461
463
  const suffix = record.type === "decision" ? `${record.meta.id}:${vectorKey(record.vector)}` : record.id;
462
- return `${(_a8 = record.phaseId) != null ? _a8 : "unscoped"}:${record.type}:${suffix}`;
464
+ // Each request that names no test keeps its own first hits: the report
465
+ // credits them to the test running when it came, and a later request's
466
+ // work belongs to whichever test was running then.
467
+ const request = record.request ? `request-${record.request}:` : "";
468
+ return `${request}${(_a8 = record.phaseId) != null ? _a8 : "unscoped"}:${record.type}:${suffix}`;
469
+ }
470
+ // Numbers the requests a server handles that name no test.
471
+ function nextUnscopedRequest() {
472
+ state.unscopedRequests = (state.unscopedRequests || 0) + 1;
473
+ return state.unscopedRequests;
463
474
  }
464
475
  function beginBufferedServerEvidence(scope) {
465
476
  if (isBrowser)
@@ -792,7 +803,11 @@ function appendServer(record) {
792
803
  try {
793
804
  const directory = scope ? serverEvidenceDirectory(scope) : void 0;
794
805
  const path = scope ? serverEvidencePath(scope) : void 0;
795
- const serialized = __spreadValues(__spreadValues({}, record), scope ? { scope } : {});
806
+ // Work done while handling a request that named no test -- a webhook a
807
+ // mock server sends -- says so, with the request's number.
808
+ const stored = serverPhaseStorage == null ? void 0 : serverPhaseStorage.getStore();
809
+ const unscopedRequest = !scope && stored && stored.request ? { request: stored.request } : {};
810
+ const serialized = __spreadValues(__spreadValues(__spreadValues({}, record), scope ? { scope } : {}), unscopedRequest);
796
811
  if (scope && state.bufferedAttempts.has(attemptKey(scope))) {
797
812
  const key = attemptKey(scope);
798
813
  const buffered = (_c = state.serverBuffers.get(key)) != null ? _c : {
@@ -1345,8 +1360,10 @@ function requestCoverageContext(value) {
1345
1360
  }
1346
1361
  }
1347
1362
  }
1348
- const encodedScope = (_a8 = headers.get(COVERAGE_SCOPE_HEADER)) != null ? _a8 : cookies.get(COVERAGE_SCOPE_COOKIE);
1349
- const rawPhaseId = (_b = headers.get(COVERAGE_PHASE_HEADER)) != null ? _b : cookies.get(COVERAGE_PHASE_COOKIE);
1363
+ // A frame of the app on another of its hosts carries only the cookies
1364
+ // made to cross sites.
1365
+ const encodedScope = (_a8 = headers.get(COVERAGE_SCOPE_HEADER)) != null ? _a8 : cookies.has(COVERAGE_SCOPE_COOKIE) ? cookies.get(COVERAGE_SCOPE_COOKIE) : cookies.get(COVERAGE_SCOPE_SITE_COOKIE);
1366
+ const rawPhaseId = (_b = headers.get(COVERAGE_PHASE_HEADER)) != null ? _b : cookies.has(COVERAGE_PHASE_COOKIE) ? cookies.get(COVERAGE_PHASE_COOKIE) : cookies.get(COVERAGE_PHASE_SITE_COOKIE);
1350
1367
  const scope = decodeCoverageScope(typeof encodedScope === "string" ? encodedScope : void 0);
1351
1368
  // The phase cookie outlives a test in a shared browser context just like
1352
1369
  // the stored phase does (see currentPhaseId); a request carrying a scope
@@ -1383,9 +1400,24 @@ function installNodeRequestPropagation() {
1383
1400
  // the server process were created by a shared before hook.
1384
1401
  const incoming = requestCoverageContext(args[0]);
1385
1402
  if (incoming) context = incoming.scope ? incoming : context != null ? context : currentRequestContext();
1403
+ // A request that names no test, to a server no test owns, is numbered:
1404
+ // the report credits its work to the test running when it came.
1405
+ if (incoming && serverPhaseStorage && !(context && (context.scope || context.phaseId)))
1406
+ context = { request: nextUnscopedRequest() };
1407
+ // Node emits a request's body and its response's events outside the
1408
+ // handler's context, so a handler that reads the body -- `req.on('end')`
1409
+ // -- ran as no test's. The request and response carry it with them.
1410
+ if (incoming && context) {
1411
+ for (const emitter of args.slice(0, 2)) {
1412
+ if (emitter && typeof emitter === "object" && typeof emitter.emit === "function")
1413
+ emitterCoverageContexts.set(emitter, context);
1414
+ }
1415
+ }
1386
1416
  }
1387
1417
  const invoke = () => Reflect.apply(original, this, [event, ...args]);
1388
1418
  try {
1419
+ if (context && context.request && !context.scope)
1420
+ return serverPhaseStorage.run(context, () => withProbeV2Context(context, invoke));
1389
1421
  return context ? withCoverageCarrier({ version: 1, ...context }, invoke) : invoke();
1390
1422
  } finally {
1391
1423
  if (event === "close") emitterCoverageContexts.delete(this);
@@ -1406,6 +1438,8 @@ function withRequestPhase(handler, event) {
1406
1438
  // owner. Shared servers have no captured test scope and stay unattributed.
1407
1439
  const inheritedContext = requestContext && requestContext.scope ? {} : registeredContext.scope ? registeredContext : requestContext === void 0 ? currentRequestContext() : {};
1408
1440
  const context = __spreadValues(__spreadValues({}, ((_a8 = requestContext == null ? void 0 : requestContext.scope) != null ? _a8 : inheritedContext.scope) ? { scope: (_b = requestContext == null ? void 0 : requestContext.scope) != null ? _b : inheritedContext.scope } : {}), ((_c = requestContext == null ? void 0 : requestContext.phaseId) != null ? _c : inheritedContext.phaseId) ? { phaseId: (_d = requestContext == null ? void 0 : requestContext.phaseId) != null ? _d : inheritedContext.phaseId } : {});
1441
+ if (requestContext !== void 0 && !context.scope)
1442
+ context.request = nextUnscopedRequest();
1409
1443
  const invoke = () => {
1410
1444
  if (event === "connection" && context.scope && args[0] &&
1411
1445
  typeof args[0] === "object" && typeof args[0].emit === "function")
@@ -2,6 +2,11 @@ export const COVERAGE_SCOPE_HEADER = "x-supercov-scope";
2
2
  export const COVERAGE_PHASE_HEADER = "x-supercov-phase";
3
3
  export const COVERAGE_SCOPE_COOKIE = "__supercov_scope";
4
4
  export const COVERAGE_PHASE_COOKIE = "__supercov_phase";
5
+ // The same, for frames of the app on another of its hosts: a cookie that
6
+ // crosses sites must be Secure, and a Secure cookie is not sent over ws://,
7
+ // so the two cannot be one.
8
+ export const COVERAGE_SCOPE_SITE_COOKIE = "__supercov_scope_site";
9
+ export const COVERAGE_PHASE_SITE_COOKIE = "__supercov_phase_site";
5
10
  export const COVERAGE_CARRIER_ENV = "SUPERCOV_CONTEXT";
6
11
  export const DEFAULT_SERVER_EVIDENCE_ROOT = "/tmp/supercov-server-evidence";
7
12
  // Buffer as it was when Supercov loaded. Tests replace globals -- axios's set