@specific.dev/spectest 0.57.0 → 0.59.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/dist/browser-coverage.d.ts +20 -0
- package/dist/browser-coverage.js +185 -0
- package/dist/browser.js +36 -0
- package/dist/coverage.d.ts +124 -0
- package/dist/coverage.js +236 -0
- package/dist/daemon.js +138 -24
- package/dist/harness/browser-coverage.d.ts +95 -0
- package/dist/harness/browser-coverage.js +257 -0
- package/dist/harness/coverage.d.ts +23 -4
- package/dist/harness/coverage.js +50 -14
- package/dist/index.d.ts +18 -25
- package/dist/index.js +6 -16
- package/package.json +6 -1
- package/src/browser-coverage.ts +209 -0
- package/src/browser.ts +36 -0
- package/src/coverage.test.ts +192 -0
- package/src/coverage.ts +327 -0
- package/src/daemon.ts +152 -26
- package/src/harness/browser-coverage.test.ts +94 -0
- package/src/harness/browser-coverage.ts +310 -0
- package/src/harness/coverage.test.ts +46 -1
- package/src/harness/coverage.ts +53 -13
- package/src/index.ts +36 -38
package/dist/harness/coverage.js
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
//
|
|
3
3
|
// A service that opts in (`coverage: true | { command }`) gets a writable
|
|
4
4
|
// directory bind-mounted at `/spectest/coverage/` in its container. The
|
|
5
|
-
// service's own tool
|
|
5
|
+
// service's own tool, or its coverage adapters, write reports there —
|
|
6
|
+
// lcov, or the V8 coverage JSON that `NODE_V8_COVERAGE` writes. The daemon reads the
|
|
6
7
|
// directory from the VM side (no `docker exec` to fetch) after bring-up
|
|
7
8
|
// and after each test, and ships the reports **verbatim** to the control
|
|
8
9
|
// plane as a gzipped bundle. Nothing is interpreted here beyond the one
|
|
9
|
-
// contract the harness must fail a case on: at least one report,
|
|
10
|
-
//
|
|
10
|
+
// contract the harness must fail a case on: at least one report, every
|
|
11
|
+
// one well-formed. Counters may reset between dumps and a dump may be a
|
|
12
|
+
// new file: the directory forks with the environment, so it accumulates
|
|
13
|
+
// along the ancestor chain by itself. The server derives whatever it
|
|
11
14
|
// needs (today the set of source files; later, finer things) from the
|
|
12
15
|
// stored bytes, so a change in that derivation is a re-read of stored
|
|
13
16
|
// runs, never a hole in the history.
|
|
@@ -19,7 +22,7 @@ import { gzipSync } from "node:zlib";
|
|
|
19
22
|
/** Where the directory is mounted inside an opted-in container. */
|
|
20
23
|
export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
|
|
21
24
|
/** Raw bytes of reports one service may ship per capture. Reports are
|
|
22
|
-
*
|
|
25
|
+
* bounded by code size times chain depth, so anything past this is a
|
|
23
26
|
* runaway (a tool writing a new file per request), not coverage. */
|
|
24
27
|
export const COVERAGE_MAX_BYTES_PER_SERVICE = 64 * 1024 * 1024;
|
|
25
28
|
/** Root of the per-service host directories, under the workspace so the
|
|
@@ -31,6 +34,34 @@ export function coverageHostDir(workspace, service) {
|
|
|
31
34
|
export function hasLcovSourceFile(text) {
|
|
32
35
|
return /^SF:\S/m.test(text);
|
|
33
36
|
}
|
|
37
|
+
/** True when `text` is lcov-shaped: at least one line, every non-blank
|
|
38
|
+
* line an `XX:` record or `end_of_record`. An lcov with no `SF:` record
|
|
39
|
+
* at all (`TN:` alone) is well-formed — it is a report that says
|
|
40
|
+
* nothing ran. */
|
|
41
|
+
export function isLcovShaped(text) {
|
|
42
|
+
const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
43
|
+
return lines.length > 0 && lines.every((l) => /^[A-Z]{2,5}:/.test(l) || l.trim() === "end_of_record");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* True when `text` looks like a V8 coverage JSON document — what
|
|
47
|
+
* `NODE_V8_COVERAGE` / `v8.takeCoverage()` write: an object with a
|
|
48
|
+
* `result` array of scripts. A cheap shape check, not a parse (a report
|
|
49
|
+
* can be megabytes); the control plane parses for real.
|
|
50
|
+
*/
|
|
51
|
+
export function isV8CoverageJson(text) {
|
|
52
|
+
return /^\s*\{/.test(text) && /"result"\s*:\s*\[/.test(text);
|
|
53
|
+
}
|
|
54
|
+
/** True when `text` is a report spectest stores: lcov or V8 JSON. */
|
|
55
|
+
export function isWellFormedReport(text) {
|
|
56
|
+
return isLcovShaped(text) || isV8CoverageJson(text);
|
|
57
|
+
}
|
|
58
|
+
/** True when a well-formed report names no source file at all (an lcov
|
|
59
|
+
* with no `SF:`, a V8 document with an empty `result`). */
|
|
60
|
+
export function isEmptyReport(text) {
|
|
61
|
+
if (isV8CoverageJson(text))
|
|
62
|
+
return /"result"\s*:\s*\[\s*\]/.test(text);
|
|
63
|
+
return !hasLcovSourceFile(text);
|
|
64
|
+
}
|
|
34
65
|
/** Every regular file under `dir`, recursively, as paths relative to it.
|
|
35
66
|
* Hidden files are skipped (a tool's own bookkeeping — `.seq`, `.lock`,
|
|
36
67
|
* a tmp file mid-write — is not a report). */
|
|
@@ -58,9 +89,11 @@ export async function listReportFiles(dir) {
|
|
|
58
89
|
return out.sort();
|
|
59
90
|
}
|
|
60
91
|
/**
|
|
61
|
-
* Read every report under `dir`, verbatim. An empty directory, or
|
|
62
|
-
*
|
|
63
|
-
* contract is that an opted-in service always has a
|
|
92
|
+
* Read every report under `dir`, verbatim. An empty directory, or a file
|
|
93
|
+
* that is neither lcov nor V8 JSON, is an error rather than an empty
|
|
94
|
+
* capture: the contract is that an opted-in service always has a
|
|
95
|
+
* well-formed report to read. A report that names no file is fine — it
|
|
96
|
+
* says nothing ran.
|
|
64
97
|
*/
|
|
65
98
|
export async function readCoverageDir(service, dir) {
|
|
66
99
|
const names = await listReportFiles(dir);
|
|
@@ -68,12 +101,13 @@ export async function readCoverageDir(service, dir) {
|
|
|
68
101
|
return {
|
|
69
102
|
service,
|
|
70
103
|
reports: [],
|
|
71
|
-
error: `service "${service}" opted in to coverage but ${COVERAGE_CONTAINER_DIR}/ holds no report — the service (or its coverage command) must write an lcov file there`,
|
|
104
|
+
error: `service "${service}" opted in to coverage but ${COVERAGE_CONTAINER_DIR}/ holds no report — the service (or its coverage command) must write an lcov or V8 coverage JSON file there`,
|
|
72
105
|
};
|
|
73
106
|
}
|
|
74
107
|
const reports = [];
|
|
75
108
|
let total = 0;
|
|
76
|
-
let
|
|
109
|
+
let anyReport = false;
|
|
110
|
+
const malformed = [];
|
|
77
111
|
for (const name of names) {
|
|
78
112
|
let content;
|
|
79
113
|
try {
|
|
@@ -87,18 +121,20 @@ export async function readCoverageDir(service, dir) {
|
|
|
87
121
|
return {
|
|
88
122
|
service,
|
|
89
123
|
reports: [],
|
|
90
|
-
error: `service "${service}" has more than ${COVERAGE_MAX_BYTES_PER_SERVICE >> 20} MiB of reports in ${COVERAGE_CONTAINER_DIR}/ —
|
|
124
|
+
error: `service "${service}" has more than ${COVERAGE_MAX_BYTES_PER_SERVICE >> 20} MiB of reports in ${COVERAGE_CONTAINER_DIR}/ — that is a runaway, not coverage`,
|
|
91
125
|
};
|
|
92
126
|
}
|
|
93
|
-
if (
|
|
94
|
-
|
|
127
|
+
if (isWellFormedReport(content))
|
|
128
|
+
anyReport = true;
|
|
129
|
+
else
|
|
130
|
+
malformed.push(name);
|
|
95
131
|
reports.push({ name, content });
|
|
96
132
|
}
|
|
97
|
-
if (!
|
|
133
|
+
if (!anyReport || malformed.length > 0) {
|
|
98
134
|
return {
|
|
99
135
|
service,
|
|
100
136
|
reports,
|
|
101
|
-
error: `service "${service}" wrote ${reports.length} file(s) to ${COVERAGE_CONTAINER_DIR}/ but none
|
|
137
|
+
error: `service "${service}" wrote ${reports.length} file(s) to ${COVERAGE_CONTAINER_DIR}/ but ${anyReport ? `${malformed.slice(0, 5).join(", ")} ${malformed.length === 1 ? "is" : "are"} neither` : "none is"} an lcov report or a V8 coverage JSON document — only those two formats are read`,
|
|
102
138
|
};
|
|
103
139
|
}
|
|
104
140
|
return { service, reports };
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export type { Mobile, MobileApp } from "./mobile.js";
|
|
|
20
20
|
import type { Mobile, MobileApp } from "./mobile.js";
|
|
21
21
|
export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
|
|
22
22
|
import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
|
|
23
|
+
export { type CoverageAdapter, type CoverageCaptureContext, type CoverageConfigureInfo, type CoverageLoadContext, type ServiceCoverage, } from "./coverage.js";
|
|
24
|
+
import { type ServiceCoverage } from "./coverage.js";
|
|
23
25
|
export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
|
|
24
26
|
export type { CertificateDecl, DnsDecl, ProxyDecl, IngressDecl, DnsTarget, LoweredIngress, } from "./ingress.js";
|
|
25
27
|
import type { DnsTarget } from "./ingress.js";
|
|
@@ -119,20 +121,24 @@ export interface ServiceConfig {
|
|
|
119
121
|
* Opt this service in to **code coverage collection** (experimental).
|
|
120
122
|
*
|
|
121
123
|
* The service gets a writable directory bind-mounted at
|
|
122
|
-
* `/spectest/coverage/`.
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* the container, then reads every file in the directory and records the
|
|
126
|
-
* set of source files that ran. Only the `SF:` records are read.
|
|
124
|
+
* `/spectest/coverage/`. After bring-up and after every test, spectest
|
|
125
|
+
* reads every file in it — **lcov** or **V8 coverage JSON**, verbatim —
|
|
126
|
+
* and stores them with the test; the control plane derives what ran.
|
|
127
127
|
*
|
|
128
|
-
* - `true`: the
|
|
129
|
-
*
|
|
130
|
-
* - `{
|
|
131
|
-
*
|
|
128
|
+
* - `true`: the program writes its own reports there (an exit-time dump
|
|
129
|
+
* for a short-lived process, a dump on a timer, its own hook).
|
|
130
|
+
* - `{ adapters: [...] }`: spectest gets the reports out. From
|
|
131
|
+
* `@specific.dev/spectest/coverage`: `node()` (V8 JSON from a node
|
|
132
|
+
* service, through a hook spectest mounts), `browser()` (the frontend
|
|
133
|
+
* this service serves, collected from the guest browser),
|
|
134
|
+
* `command(cmd)` (run a command in the container first), or your own
|
|
135
|
+
* via `defineAdapter`.
|
|
132
136
|
*
|
|
133
|
-
* The directory must hold at least one
|
|
134
|
-
* an empty directory or a
|
|
135
|
-
* error, so a hole in the coverage data is never silent.
|
|
137
|
+
* The directory must hold at least one well-formed report each time it
|
|
138
|
+
* is read; an empty directory or a failing adapter fails the test with a
|
|
139
|
+
* clear error, so a hole in the coverage data is never silent. Reports
|
|
140
|
+
* accumulate along the test's ancestor chain by themselves (the
|
|
141
|
+
* directory forks with the environment). See
|
|
136
142
|
* `spectest docs /services/coverage`.
|
|
137
143
|
*/
|
|
138
144
|
coverage?: ServiceCoverage;
|
|
@@ -663,14 +669,6 @@ export type ServiceImage = {
|
|
|
663
669
|
/** Extra glob patterns to exclude from the build context. */
|
|
664
670
|
exclude?: readonly string[];
|
|
665
671
|
};
|
|
666
|
-
/**
|
|
667
|
-
* A service's opt-in to coverage collection — see `ServiceConfig.coverage`.
|
|
668
|
-
* `true` when the service writes its own reports; `{ command }` when
|
|
669
|
-
* spectest must run a command in the container to produce one.
|
|
670
|
-
*/
|
|
671
|
-
export type ServiceCoverage = true | {
|
|
672
|
-
command: string;
|
|
673
|
-
};
|
|
674
672
|
export interface VolumeMount {
|
|
675
673
|
/**
|
|
676
674
|
* Named shared volume. Two services mounting the same `name` share one
|
|
@@ -883,11 +881,6 @@ export interface FakeContext {
|
|
|
883
881
|
* for a TLS-provisioning provider hands back to the app under test. */
|
|
884
882
|
certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
|
|
885
883
|
}
|
|
886
|
-
/**
|
|
887
|
-
* Check one service's `coverage` field. Exported so the daemon applies the
|
|
888
|
-
* same rule to a runtime `startService` spec.
|
|
889
|
-
*/
|
|
890
|
-
export declare function validateCoverage(service: string, cov: unknown): void;
|
|
891
884
|
/**
|
|
892
885
|
* A single test step. Created via `test(...)` or `createTest(services)`.
|
|
893
886
|
* Tests are referenced (not named by string) when one test depends on
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,7 @@ export { McpHttpError, McpRpcError, McpAuthDeniedError, } from "./mcp.js";
|
|
|
40
40
|
import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, } from "./locator.js";
|
|
41
41
|
import { formatWaited, locatorFailureMessage } from "./locator-errors.js";
|
|
42
42
|
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
43
|
+
import { applyCoverageAdapters, validateCoverage } from "./coverage.js";
|
|
43
44
|
// Low-level ingress primitives + the framework lowering that the friendly
|
|
44
45
|
// `tls` / `hostnames` fields and `defineFake(...)` are built on. See
|
|
45
46
|
// `ingress.ts`.
|
|
@@ -215,21 +216,6 @@ function expandServiceGroups(input) {
|
|
|
215
216
|
}
|
|
216
217
|
return out;
|
|
217
218
|
}
|
|
218
|
-
/**
|
|
219
|
-
* Check one service's `coverage` field. Exported so the daemon applies the
|
|
220
|
-
* same rule to a runtime `startService` spec.
|
|
221
|
-
*/
|
|
222
|
-
export function validateCoverage(service, cov) {
|
|
223
|
-
if (cov === undefined || cov === true)
|
|
224
|
-
return;
|
|
225
|
-
if (typeof cov === "object" &&
|
|
226
|
-
cov !== null &&
|
|
227
|
-
typeof cov.command === "string" &&
|
|
228
|
-
cov.command.trim().length > 0) {
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
throw new Error(`service "${service}" has an invalid \`coverage\` value — use \`true\` or \`{ command: "<shell command>" }\``);
|
|
232
|
-
}
|
|
233
219
|
function validateEnvironmentConfig(config) {
|
|
234
220
|
const entries = Object.entries(config.services);
|
|
235
221
|
const serviceNames = new Set(entries.map(([n]) => n));
|
|
@@ -337,9 +323,13 @@ export function defineEnvironment(input) {
|
|
|
337
323
|
// { name, services, timeoutSecs } — with plain, fully-expanded services —
|
|
338
324
|
// is stored as `config` and shipped to the control plane.
|
|
339
325
|
const { fakes, services: rawServices, ...rest } = input;
|
|
326
|
+
const expanded = expandServiceGroups(rawServices);
|
|
327
|
+
for (const [key, svc] of Object.entries(expanded)) {
|
|
328
|
+
expanded[key] = applyCoverageAdapters(key, svc);
|
|
329
|
+
}
|
|
340
330
|
const config = {
|
|
341
331
|
...rest,
|
|
342
|
-
services:
|
|
332
|
+
services: expanded,
|
|
343
333
|
};
|
|
344
334
|
validateEnvironmentConfig(config);
|
|
345
335
|
if (fakes)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specific.dev/spectest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
4
4
|
"description": "Spectest SDK for defining test environments in TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
"bun": "./src/components/index.ts",
|
|
17
17
|
"default": "./dist/components/index.js"
|
|
18
18
|
},
|
|
19
|
+
"./coverage": {
|
|
20
|
+
"types": "./dist/coverage.d.ts",
|
|
21
|
+
"bun": "./src/coverage.ts",
|
|
22
|
+
"default": "./dist/coverage.js"
|
|
23
|
+
},
|
|
19
24
|
"./browser": {
|
|
20
25
|
"types": "./dist/browser.d.ts",
|
|
21
26
|
"bun": "./src/browser.ts",
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Browser coverage — the collector.
|
|
2
|
+
//
|
|
3
|
+
// One collector per CDP session (i.e. per page view). `attach` turns on
|
|
4
|
+
// V8 precise coverage and tracks parsed scripts; `harvest` takes the
|
|
5
|
+
// counters (which RESETS them — V8 reports nothing for a script that ran
|
|
6
|
+
// nothing since the last take, so the counts are summed here across
|
|
7
|
+
// takes), maps each script the browser loaded from a covered service back
|
|
8
|
+
// to its original sources through the served source map, and folds the
|
|
9
|
+
// result into a per-service total in module memory — which forks with the
|
|
10
|
+
// daemon, so a child test inherits its ancestors' browser coverage exactly
|
|
11
|
+
// as a container's counters are inherited.
|
|
12
|
+
//
|
|
13
|
+
// Harvest runs before every browser step (a click that navigates
|
|
14
|
+
// cross-site replaces the renderer, and the old counters with it) and at
|
|
15
|
+
// every coverage capture. `browserCoverageReports` renders the totals as
|
|
16
|
+
// one lcov per service for the `case-coverage` bundle.
|
|
17
|
+
//
|
|
18
|
+
// Configured by the daemon at /load with the host → service table; a
|
|
19
|
+
// project with no `coverage: { browser: true }` service never attaches
|
|
20
|
+
// anything, so it costs nothing.
|
|
21
|
+
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import type { CDPSession } from "playwright-core";
|
|
24
|
+
import {
|
|
25
|
+
type DecodedSourceMap,
|
|
26
|
+
type FileCounts,
|
|
27
|
+
decodeSourceMap,
|
|
28
|
+
lcovDocument,
|
|
29
|
+
mapToOriginal,
|
|
30
|
+
mergeFileCounts,
|
|
31
|
+
rangesToLineCounts,
|
|
32
|
+
type V8FunctionCoverage,
|
|
33
|
+
} from "./harness/browser-coverage.js";
|
|
34
|
+
import { rawFetch } from "./harness/raw-fetch.js";
|
|
35
|
+
|
|
36
|
+
export interface BrowserCoverageConfig {
|
|
37
|
+
/** Which covered service (if any) serves scripts from this host. */
|
|
38
|
+
serviceForHost(host: string): string | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let CONFIG: BrowserCoverageConfig | null = null;
|
|
42
|
+
|
|
43
|
+
/** Set (or clear, with `null`) at /load. */
|
|
44
|
+
export function configureBrowserCoverage(config: BrowserCoverageConfig | null): void {
|
|
45
|
+
CONFIG = config;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function browserCoverageActive(): boolean {
|
|
49
|
+
return CONFIG !== null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** service → file → line → count, summed over every harvest. */
|
|
53
|
+
const TOTALS = new Map<string, FileCounts>();
|
|
54
|
+
|
|
55
|
+
interface ScriptInfo {
|
|
56
|
+
url: string;
|
|
57
|
+
sourceMapURL?: string;
|
|
58
|
+
source?: string;
|
|
59
|
+
/** `undefined` = not looked up yet; `null` = none / unusable. */
|
|
60
|
+
map?: DecodedSourceMap | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Collector {
|
|
64
|
+
cdp: CDPSession;
|
|
65
|
+
scripts: Map<string, ScriptInfo>;
|
|
66
|
+
inFlight: Promise<void> | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const COLLECTORS = new WeakMap<CDPSession, Collector>();
|
|
70
|
+
|
|
71
|
+
/** Decoded maps by URL (or content hash for inline `data:` maps). */
|
|
72
|
+
const SOURCE_MAPS = new Map<string, DecodedSourceMap | null>();
|
|
73
|
+
|
|
74
|
+
const SOURCE_MAP_FETCH_TIMEOUT_MS = 10_000;
|
|
75
|
+
|
|
76
|
+
/** Turn coverage on for a page's CDP session. Idempotent; a no-op when no
|
|
77
|
+
* service opted in. */
|
|
78
|
+
export async function attachBrowserCoverage(cdp: CDPSession): Promise<void> {
|
|
79
|
+
if (!CONFIG || COLLECTORS.has(cdp)) return;
|
|
80
|
+
const collector: Collector = { cdp, scripts: new Map(), inFlight: null };
|
|
81
|
+
COLLECTORS.set(cdp, collector);
|
|
82
|
+
cdp.on("Debugger.scriptParsed", (ev: { scriptId: string; url: string; sourceMapURL?: string }) => {
|
|
83
|
+
if (!ev.url) return;
|
|
84
|
+
collector.scripts.set(ev.scriptId, {
|
|
85
|
+
url: ev.url,
|
|
86
|
+
sourceMapURL: ev.sourceMapURL || undefined,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
await cdp.send("Debugger.enable");
|
|
90
|
+
await cdp.send("Profiler.enable");
|
|
91
|
+
await cdp.send("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Take the session's counters and fold them into the totals. Concurrent
|
|
96
|
+
* calls coalesce onto the in-flight one. Errors (a page mid-navigation,
|
|
97
|
+
* a closed view) are swallowed: the counters then stay in V8 for the next
|
|
98
|
+
* take, nothing is lost.
|
|
99
|
+
*/
|
|
100
|
+
export async function harvestBrowserCoverage(cdp: CDPSession): Promise<void> {
|
|
101
|
+
const c = COLLECTORS.get(cdp);
|
|
102
|
+
if (!c || !CONFIG) return;
|
|
103
|
+
if (c.inFlight) return c.inFlight;
|
|
104
|
+
c.inFlight = harvestInner(c, CONFIG).catch(() => {}).finally(() => {
|
|
105
|
+
c.inFlight = null;
|
|
106
|
+
});
|
|
107
|
+
return c.inFlight;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function harvestInner(c: Collector, config: BrowserCoverageConfig): Promise<void> {
|
|
111
|
+
const { result } = (await c.cdp.send("Profiler.takePreciseCoverage")) as {
|
|
112
|
+
result: { scriptId: string; url: string; functions: V8FunctionCoverage[] }[];
|
|
113
|
+
};
|
|
114
|
+
for (const script of result) {
|
|
115
|
+
const info = c.scripts.get(script.scriptId) ?? (script.url ? { url: script.url } : undefined);
|
|
116
|
+
if (!info) continue;
|
|
117
|
+
let url: URL;
|
|
118
|
+
try {
|
|
119
|
+
url = new URL(info.url);
|
|
120
|
+
} catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") continue;
|
|
124
|
+
const service = config.serviceForHost(url.hostname);
|
|
125
|
+
if (!service) continue;
|
|
126
|
+
if (info.source === undefined) {
|
|
127
|
+
try {
|
|
128
|
+
const r = (await c.cdp.send("Debugger.getScriptSource", {
|
|
129
|
+
scriptId: script.scriptId,
|
|
130
|
+
})) as { scriptSource: string };
|
|
131
|
+
info.source = r.scriptSource;
|
|
132
|
+
} catch {
|
|
133
|
+
continue; // script gone with its document
|
|
134
|
+
}
|
|
135
|
+
c.scripts.set(script.scriptId, info);
|
|
136
|
+
}
|
|
137
|
+
const generated = rangesToLineCounts(info.source, script.functions);
|
|
138
|
+
if (generated.size === 0) continue;
|
|
139
|
+
if (info.map === undefined) info.map = await sourceMapFor(info, url);
|
|
140
|
+
const files: FileCounts = info.map
|
|
141
|
+
? mapToOriginal(info.map, generated)
|
|
142
|
+
: new Map([[url.pathname, generated]]);
|
|
143
|
+
let totals = TOTALS.get(service);
|
|
144
|
+
if (!totals) {
|
|
145
|
+
totals = new Map();
|
|
146
|
+
TOTALS.set(service, totals);
|
|
147
|
+
}
|
|
148
|
+
mergeFileCounts(totals, files);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Resolve, fetch (or inline-decode) and decode a script's source map. */
|
|
153
|
+
async function sourceMapFor(info: ScriptInfo, scriptUrl: URL): Promise<DecodedSourceMap | null> {
|
|
154
|
+
let ref = info.sourceMapURL;
|
|
155
|
+
if (!ref && info.source) {
|
|
156
|
+
const m = /\/\/[#@]\s*sourceMappingURL=(\S+)\s*$/.exec(info.source.slice(-4096));
|
|
157
|
+
if (m) ref = m[1];
|
|
158
|
+
}
|
|
159
|
+
if (!ref) return null;
|
|
160
|
+
if (ref.startsWith("data:")) {
|
|
161
|
+
const key = "data:" + createHash("sha1").update(ref).digest("hex");
|
|
162
|
+
const cached = SOURCE_MAPS.get(key);
|
|
163
|
+
if (cached !== undefined) return cached;
|
|
164
|
+
const map = decodeSourceMap(parseDataUrl(ref));
|
|
165
|
+
SOURCE_MAPS.set(key, map);
|
|
166
|
+
return map;
|
|
167
|
+
}
|
|
168
|
+
let mapUrl: string;
|
|
169
|
+
try {
|
|
170
|
+
mapUrl = new URL(ref, scriptUrl).toString();
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const cached = SOURCE_MAPS.get(mapUrl);
|
|
175
|
+
if (cached !== undefined) return cached;
|
|
176
|
+
let map: DecodedSourceMap | null = null;
|
|
177
|
+
try {
|
|
178
|
+
const res = await rawFetch(mapUrl, { signal: AbortSignal.timeout(SOURCE_MAP_FETCH_TIMEOUT_MS) });
|
|
179
|
+
if (res.ok) map = decodeSourceMap(await res.json());
|
|
180
|
+
} catch {
|
|
181
|
+
map = null;
|
|
182
|
+
}
|
|
183
|
+
SOURCE_MAPS.set(mapUrl, map);
|
|
184
|
+
return map;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseDataUrl(ref: string): unknown {
|
|
188
|
+
const comma = ref.indexOf(",");
|
|
189
|
+
if (comma < 0) return null;
|
|
190
|
+
const meta = ref.slice(5, comma);
|
|
191
|
+
const payload = ref.slice(comma + 1);
|
|
192
|
+
try {
|
|
193
|
+
const text = /;base64$/i.test(meta)
|
|
194
|
+
? Buffer.from(payload, "base64").toString("utf8")
|
|
195
|
+
: decodeURIComponent(payload);
|
|
196
|
+
return JSON.parse(text);
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** One lcov document per service that has any browser coverage. */
|
|
203
|
+
export function browserCoverageReports(): Map<string, string> {
|
|
204
|
+
const out = new Map<string, string>();
|
|
205
|
+
for (const [service, files] of TOTALS) {
|
|
206
|
+
if (files.size > 0) out.set(service, lcovDocument(files));
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
package/src/browser.ts
CHANGED
|
@@ -38,6 +38,11 @@ import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./r
|
|
|
38
38
|
import { wrap } from "./inspect.js";
|
|
39
39
|
import type { Wrapped } from "./inspect.js";
|
|
40
40
|
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
41
|
+
import {
|
|
42
|
+
attachBrowserCoverage,
|
|
43
|
+
browserCoverageActive,
|
|
44
|
+
harvestBrowserCoverage,
|
|
45
|
+
} from "./browser-coverage.js";
|
|
41
46
|
import type { UrlPattern } from "./url-match.js";
|
|
42
47
|
export type { UrlPattern } from "./url-match.js";
|
|
43
48
|
import {
|
|
@@ -1223,6 +1228,13 @@ async function spawnPage(context: BrowserContext): Promise<SpawnedPage> {
|
|
|
1223
1228
|
console.warn("[spectest] failed to install rrweb recorder:", err);
|
|
1224
1229
|
}
|
|
1225
1230
|
}
|
|
1231
|
+
// Frontend coverage (opt-in): turn on V8 precise coverage for this page.
|
|
1232
|
+
// A no-op unless a service declared `coverage: { browser: true }`.
|
|
1233
|
+
try {
|
|
1234
|
+
await attachBrowserCoverage(cdp);
|
|
1235
|
+
} catch {
|
|
1236
|
+
/* coverage is best-effort; a page without it still works */
|
|
1237
|
+
}
|
|
1226
1238
|
return { page, cdp, recordingInstalled };
|
|
1227
1239
|
}
|
|
1228
1240
|
|
|
@@ -1558,6 +1570,13 @@ async function rebuildView(holder: ViewHolder): Promise<void> {
|
|
|
1558
1570
|
} catch {
|
|
1559
1571
|
/* page may already be gone */
|
|
1560
1572
|
}
|
|
1573
|
+
// The old renderer's coverage counters die with its page; harvest what
|
|
1574
|
+
// it had before the swap (rebuild is triggered on a fresh navigation).
|
|
1575
|
+
try {
|
|
1576
|
+
await harvestBrowserCoverage(holder.cdp);
|
|
1577
|
+
} catch {
|
|
1578
|
+
/* best-effort */
|
|
1579
|
+
}
|
|
1561
1580
|
const fresh = await spawnPage(holder.context);
|
|
1562
1581
|
holder.page = fresh.page;
|
|
1563
1582
|
holder.cdp = fresh.cdp;
|
|
@@ -1660,6 +1679,16 @@ function buildBackend(
|
|
|
1660
1679
|
durationMs: endT - t,
|
|
1661
1680
|
}, resv);
|
|
1662
1681
|
await drain(action);
|
|
1682
|
+
// Fold this page's V8 counters into the per-service totals now — a
|
|
1683
|
+
// navigation to another origin replaces the renderer and its
|
|
1684
|
+
// counters, so harvesting per op is what keeps them.
|
|
1685
|
+
if (browserCoverageActive()) {
|
|
1686
|
+
try {
|
|
1687
|
+
await harvestBrowserCoverage(holder.cdp);
|
|
1688
|
+
} catch {
|
|
1689
|
+
/* best-effort */
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1663
1692
|
// Reads (`opts.wrap`) return user-visible JS values someone is likely to
|
|
1664
1693
|
// assert on — provenance-wrap so `expect(...)` nests under this step.
|
|
1665
1694
|
// Actions return void/internals; leave them raw to avoid Proxy surprises.
|
|
@@ -1688,6 +1717,13 @@ function buildBackend(
|
|
|
1688
1717
|
if (recordingEnded) return;
|
|
1689
1718
|
// Final drain before we stop writing to this recorder.
|
|
1690
1719
|
await drain("close");
|
|
1720
|
+
if (browserCoverageActive()) {
|
|
1721
|
+
try {
|
|
1722
|
+
await harvestBrowserCoverage(holder.cdp);
|
|
1723
|
+
} catch {
|
|
1724
|
+
/* best-effort */
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1691
1727
|
recordingEnded = true;
|
|
1692
1728
|
}
|
|
1693
1729
|
|