@specific.dev/spectest 0.57.0 → 0.58.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/daemon.js +72 -11
- package/dist/harness/browser-coverage.d.ts +95 -0
- package/dist/harness/browser-coverage.js +257 -0
- package/dist/index.d.ts +22 -1
- package/dist/index.js +22 -6
- package/package.json +1 -1
- package/src/browser-coverage.ts +209 -0
- package/src/browser.ts +36 -0
- package/src/daemon.ts +78 -13
- package/src/harness/browser-coverage.test.ts +94 -0
- package/src/harness/browser-coverage.ts +310 -0
- package/src/index.ts +42 -9
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Browser coverage — the pure part.
|
|
2
|
+
//
|
|
3
|
+
// A frontend runs in the guest Chromium, which is spectest's own process,
|
|
4
|
+
// so the user cannot write a coverage report for it the way a service
|
|
5
|
+
// process can. A service declares `coverage: { browser: true }` and
|
|
6
|
+
// spectest collects it: V8 precise coverage over CDP for every script the
|
|
7
|
+
// browser loads from that service, mapped back to original sources
|
|
8
|
+
// through the served source maps, and written as one lcov report per
|
|
9
|
+
// service into the same `case-coverage` bundle the container path uses.
|
|
10
|
+
//
|
|
11
|
+
// This module has no CDP in it: source-map decoding, V8 range → line
|
|
12
|
+
// counts, host → service attribution, lcov output. The collector that
|
|
13
|
+
// talks to the page is `../browser-coverage.ts`.
|
|
14
|
+
// ── V8 precise coverage → generated line counts ─────────────────────────
|
|
15
|
+
/** Byte offset at which each line starts. */
|
|
16
|
+
export function lineStartsOf(source) {
|
|
17
|
+
const starts = [0];
|
|
18
|
+
for (let i = 0; i < source.length; i++) {
|
|
19
|
+
if (source.charCodeAt(i) === 10)
|
|
20
|
+
starts.push(i + 1);
|
|
21
|
+
}
|
|
22
|
+
return starts;
|
|
23
|
+
}
|
|
24
|
+
/** 1-based line holding `offset`. */
|
|
25
|
+
export function lineOf(starts, offset) {
|
|
26
|
+
let lo = 0;
|
|
27
|
+
let hi = starts.length - 1;
|
|
28
|
+
while (lo < hi) {
|
|
29
|
+
const mid = (lo + hi + 1) >> 1;
|
|
30
|
+
if (starts[mid] <= offset)
|
|
31
|
+
lo = mid;
|
|
32
|
+
else
|
|
33
|
+
hi = mid - 1;
|
|
34
|
+
}
|
|
35
|
+
return lo + 1;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Fold a script's block ranges onto its lines. Ranges come outer-first
|
|
39
|
+
* and a narrower range overrides the lines it spans, so a line inside an
|
|
40
|
+
* unexecuted branch ends at 0 even though its enclosing function ran.
|
|
41
|
+
* Blank lines are dropped. Approximate — a line holding two blocks gets
|
|
42
|
+
* the narrower one's count — which is exact at the file level, the only
|
|
43
|
+
* level anything reads today.
|
|
44
|
+
*/
|
|
45
|
+
export function rangesToLineCounts(source, functions) {
|
|
46
|
+
const starts = lineStartsOf(source);
|
|
47
|
+
const counts = new Map();
|
|
48
|
+
for (const fn of functions) {
|
|
49
|
+
for (const r of fn.ranges) {
|
|
50
|
+
const a = lineOf(starts, r.startOffset);
|
|
51
|
+
const b = lineOf(starts, Math.max(r.startOffset, r.endOffset - 1));
|
|
52
|
+
for (let l = a; l <= b; l++)
|
|
53
|
+
counts.set(l, r.count);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const l of [...counts.keys()]) {
|
|
57
|
+
const text = source.slice(starts[l - 1], starts[l] ?? source.length);
|
|
58
|
+
if (text.trim().length === 0)
|
|
59
|
+
counts.delete(l);
|
|
60
|
+
}
|
|
61
|
+
return counts;
|
|
62
|
+
}
|
|
63
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
64
|
+
const B64_INDEX = new Map([...B64].map((c, i) => [c, i]));
|
|
65
|
+
/** Decode one VLQ-encoded segment into its fields. */
|
|
66
|
+
export function decodeVlq(segment) {
|
|
67
|
+
const out = [];
|
|
68
|
+
let value = 0;
|
|
69
|
+
let shift = 0;
|
|
70
|
+
for (const ch of segment) {
|
|
71
|
+
const digit = B64_INDEX.get(ch);
|
|
72
|
+
if (digit === undefined)
|
|
73
|
+
throw new Error(`bad VLQ char ${JSON.stringify(ch)}`);
|
|
74
|
+
value += (digit & 31) << shift;
|
|
75
|
+
if (digit & 32) {
|
|
76
|
+
shift += 5;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const negative = value & 1;
|
|
80
|
+
value >>>= 1;
|
|
81
|
+
out.push(negative ? -value : value);
|
|
82
|
+
value = 0;
|
|
83
|
+
shift = 0;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Normalise a source-map `sources` entry to a plain path. Bundlers prefix
|
|
90
|
+
* their own scheme (`webpack:///./src/a.ts`), dev servers serve absolute
|
|
91
|
+
* URLs paths (`/src/a.ts`), builds write relative ones (`../src/a.ts`).
|
|
92
|
+
* The server maps these onto the repo by suffix, so all that matters here
|
|
93
|
+
* is stripping what is not a path.
|
|
94
|
+
*/
|
|
95
|
+
export function normalizeSourcePath(src, sourceRoot) {
|
|
96
|
+
let s = src;
|
|
97
|
+
if (sourceRoot && !/^[a-z]+:/i.test(s)) {
|
|
98
|
+
s = sourceRoot.replace(/\/?$/, "/") + s;
|
|
99
|
+
}
|
|
100
|
+
s = s.replace(/^webpack:\/\/\/?/, "").replace(/^file:\/\//, "");
|
|
101
|
+
// A URL (`http://host/src/a.ts`, Vite's `/@fs/…`) keeps only its path.
|
|
102
|
+
const m = /^[a-z]+:\/\/[^/]*(\/.*)$/i.exec(s);
|
|
103
|
+
if (m)
|
|
104
|
+
s = m[1];
|
|
105
|
+
s = s.replace(/^\/@fs\//, "/");
|
|
106
|
+
s = s.replace(/\?.*$/, "");
|
|
107
|
+
while (s.startsWith("./"))
|
|
108
|
+
s = s.slice(2);
|
|
109
|
+
return s;
|
|
110
|
+
}
|
|
111
|
+
/** Sources nobody's change can be attributed to: dependencies and bundler
|
|
112
|
+
* runtime shims. */
|
|
113
|
+
export function isForeignSource(path) {
|
|
114
|
+
return (path.length === 0 ||
|
|
115
|
+
path.includes("node_modules/") ||
|
|
116
|
+
path.startsWith("webpack/") ||
|
|
117
|
+
path.startsWith("(webpack)") ||
|
|
118
|
+
path.startsWith("\0") ||
|
|
119
|
+
path.startsWith("vite/") ||
|
|
120
|
+
path.startsWith("@vite/"));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Decode a source map document. Index maps (`sections`) are not
|
|
124
|
+
* supported and decode to `null`, as does anything malformed — the
|
|
125
|
+
* caller then falls back to attributing the script to its own URL, which
|
|
126
|
+
* the repo mapping cannot resolve, which is the safe answer.
|
|
127
|
+
*/
|
|
128
|
+
export function decodeSourceMap(doc) {
|
|
129
|
+
if (!doc || typeof doc !== "object")
|
|
130
|
+
return null;
|
|
131
|
+
const m = doc;
|
|
132
|
+
if (m.sections || !Array.isArray(m.sources) || typeof m.mappings !== "string") {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const sourceRoot = typeof m.sourceRoot === "string" ? m.sourceRoot : undefined;
|
|
136
|
+
const sources = m.sources.map((s) => typeof s === "string" ? normalizeSourcePath(s, sourceRoot) : "");
|
|
137
|
+
const lines = [];
|
|
138
|
+
let src = 0;
|
|
139
|
+
let line = 0;
|
|
140
|
+
try {
|
|
141
|
+
for (const group of m.mappings.split(";")) {
|
|
142
|
+
const segs = [];
|
|
143
|
+
let col = 0;
|
|
144
|
+
if (group.length > 0) {
|
|
145
|
+
for (const seg of group.split(",")) {
|
|
146
|
+
if (seg.length === 0)
|
|
147
|
+
continue;
|
|
148
|
+
const f = decodeVlq(seg);
|
|
149
|
+
col += f[0];
|
|
150
|
+
if (f.length >= 4) {
|
|
151
|
+
src += f[1];
|
|
152
|
+
line += f[2];
|
|
153
|
+
segs.push({ col, src, line });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
lines.push(segs);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
return { sources, lines };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Attribute generated line counts to original (file, line)s through the
|
|
167
|
+
* map. Every original line a generated line's segments name gets that
|
|
168
|
+
* line's count (max over the generated lines that touch it). Foreign
|
|
169
|
+
* sources are dropped.
|
|
170
|
+
*/
|
|
171
|
+
export function mapToOriginal(map, generated) {
|
|
172
|
+
const out = new Map();
|
|
173
|
+
for (const [genLine, count] of generated) {
|
|
174
|
+
const segs = map.lines[genLine - 1];
|
|
175
|
+
if (!segs || segs.length === 0)
|
|
176
|
+
continue;
|
|
177
|
+
for (const seg of segs) {
|
|
178
|
+
const file = map.sources[seg.src];
|
|
179
|
+
if (file === undefined || isForeignSource(file))
|
|
180
|
+
continue;
|
|
181
|
+
let lc = out.get(file);
|
|
182
|
+
if (!lc) {
|
|
183
|
+
lc = new Map();
|
|
184
|
+
out.set(file, lc);
|
|
185
|
+
}
|
|
186
|
+
const l = seg.line + 1;
|
|
187
|
+
lc.set(l, Math.max(lc.get(l) ?? 0, count));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
// ── Accumulation + lcov ─────────────────────────────────────────────────
|
|
193
|
+
/** Add `from` into `into` (sums — takes reset V8's counters). */
|
|
194
|
+
export function mergeLineCounts(into, from) {
|
|
195
|
+
for (const [l, c] of from)
|
|
196
|
+
into.set(l, (into.get(l) ?? 0) + c);
|
|
197
|
+
}
|
|
198
|
+
export function mergeFileCounts(into, from) {
|
|
199
|
+
for (const [file, counts] of from) {
|
|
200
|
+
let target = into.get(file);
|
|
201
|
+
if (!target) {
|
|
202
|
+
target = new Map();
|
|
203
|
+
into.set(file, target);
|
|
204
|
+
}
|
|
205
|
+
mergeLineCounts(target, counts);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** One lcov document for a set of files. */
|
|
209
|
+
export function lcovDocument(files) {
|
|
210
|
+
let out = "TN:\n";
|
|
211
|
+
for (const [file, counts] of [...files].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
212
|
+
out += `SF:${file}\n`;
|
|
213
|
+
let found = 0;
|
|
214
|
+
let hit = 0;
|
|
215
|
+
for (const [l, c] of [...counts].sort((a, b) => a[0] - b[0])) {
|
|
216
|
+
out += `DA:${l},${c}\n`;
|
|
217
|
+
found++;
|
|
218
|
+
if (c > 0)
|
|
219
|
+
hit++;
|
|
220
|
+
}
|
|
221
|
+
out += `LF:${found}\nLH:${hit}\nend_of_record\n`;
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Which service a script URL's host belongs to: the service key itself,
|
|
227
|
+
* `<key>.internal`, its `hostnames`/`dnsName` aliases, a `tls`/`proxy`
|
|
228
|
+
* hostname routed to it, or a wildcard pointed at it. Exact first, then
|
|
229
|
+
* the longest wildcard suffix — the resolver's own precedence.
|
|
230
|
+
*/
|
|
231
|
+
export function serviceForHost(host, services, table) {
|
|
232
|
+
const h = host.toLowerCase().replace(/\.$/, "");
|
|
233
|
+
for (const s of services) {
|
|
234
|
+
if (h === s.toLowerCase() || h === `${s.toLowerCase()}.internal`)
|
|
235
|
+
return s;
|
|
236
|
+
}
|
|
237
|
+
for (const [svc, aliases] of Object.entries(table.aliasesByService)) {
|
|
238
|
+
if (aliases.some((a) => a.toLowerCase() === h))
|
|
239
|
+
return svc;
|
|
240
|
+
}
|
|
241
|
+
for (const p of table.proxies) {
|
|
242
|
+
if (p.hostname.toLowerCase() === h)
|
|
243
|
+
return p.service;
|
|
244
|
+
}
|
|
245
|
+
let best;
|
|
246
|
+
for (const w of table.wildcards) {
|
|
247
|
+
const service = w.target.service;
|
|
248
|
+
if (!service)
|
|
249
|
+
continue;
|
|
250
|
+
const suffix = w.pattern.toLowerCase().replace(/^\*/, "");
|
|
251
|
+
if (h.endsWith(suffix) && h.length > suffix.length) {
|
|
252
|
+
if (!best || suffix.length > best.len)
|
|
253
|
+
best = { len: suffix.length, service };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return best?.service;
|
|
257
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -669,7 +669,22 @@ export type ServiceImage = {
|
|
|
669
669
|
* spectest must run a command in the container to produce one.
|
|
670
670
|
*/
|
|
671
671
|
export type ServiceCoverage = true | {
|
|
672
|
-
|
|
672
|
+
/** Run this (via `sh -c`) in the container before the coverage
|
|
673
|
+
* directory is read, so the service writes a fresh lcov report. */
|
|
674
|
+
command?: string;
|
|
675
|
+
/**
|
|
676
|
+
* Collect **frontend** coverage for the app this service serves.
|
|
677
|
+
* The code runs in the guest browser — spectest's own process — so
|
|
678
|
+
* the user cannot write a report for it: spectest turns on V8
|
|
679
|
+
* coverage for every script `ctx.browser()`/`ctx.mobile()` loads
|
|
680
|
+
* from this service's origin, maps it back to original sources
|
|
681
|
+
* through the served source maps, and adds one lcov report to this
|
|
682
|
+
* service's capture. Scripts a source map can't resolve are
|
|
683
|
+
* attributed to their served path (which the repo mapping then
|
|
684
|
+
* treats as unknown — the safe answer). Needs the app to serve
|
|
685
|
+
* source maps to the test environment.
|
|
686
|
+
*/
|
|
687
|
+
browser?: boolean;
|
|
673
688
|
};
|
|
674
689
|
export interface VolumeMount {
|
|
675
690
|
/**
|
|
@@ -888,6 +903,12 @@ export interface FakeContext {
|
|
|
888
903
|
* same rule to a runtime `startService` spec.
|
|
889
904
|
*/
|
|
890
905
|
export declare function validateCoverage(service: string, cov: unknown): void;
|
|
906
|
+
/** Whether a `coverage` value collects reports from the container's own
|
|
907
|
+
* filesystem (`/spectest/coverage/`). True for `true`, `{ command }`, and
|
|
908
|
+
* any object that isn't browser-only. */
|
|
909
|
+
export declare function coverageUsesContainer(cov: ServiceCoverage | undefined): boolean;
|
|
910
|
+
/** Whether a `coverage` value collects frontend (browser) coverage. */
|
|
911
|
+
export declare function coverageUsesBrowser(cov: ServiceCoverage | undefined): boolean;
|
|
891
912
|
/**
|
|
892
913
|
* A single test step. Created via `test(...)` or `createTest(services)`.
|
|
893
914
|
* Tests are referenced (not named by string) when one test depends on
|
package/dist/index.js
CHANGED
|
@@ -222,13 +222,29 @@ function expandServiceGroups(input) {
|
|
|
222
222
|
export function validateCoverage(service, cov) {
|
|
223
223
|
if (cov === undefined || cov === true)
|
|
224
224
|
return;
|
|
225
|
-
if (typeof cov === "object" &&
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
225
|
+
if (typeof cov === "object" && cov !== null) {
|
|
226
|
+
const o = cov;
|
|
227
|
+
const cmdOk = o.command === undefined ||
|
|
228
|
+
(typeof o.command === "string" && o.command.trim().length > 0);
|
|
229
|
+
const browserOk = o.browser === undefined || typeof o.browser === "boolean";
|
|
230
|
+
if (cmdOk && browserOk && (o.command !== undefined || o.browser === true))
|
|
231
|
+
return;
|
|
230
232
|
}
|
|
231
|
-
throw new Error(`service "${service}" has an invalid \`coverage\` value — use \`true
|
|
233
|
+
throw new Error(`service "${service}" has an invalid \`coverage\` value — use \`true\`, \`{ command: "<shell command>" }\`, or \`{ browser: true }\``);
|
|
234
|
+
}
|
|
235
|
+
/** Whether a `coverage` value collects reports from the container's own
|
|
236
|
+
* filesystem (`/spectest/coverage/`). True for `true`, `{ command }`, and
|
|
237
|
+
* any object that isn't browser-only. */
|
|
238
|
+
export function coverageUsesContainer(cov) {
|
|
239
|
+
if (cov === undefined)
|
|
240
|
+
return false;
|
|
241
|
+
if (cov === true)
|
|
242
|
+
return true;
|
|
243
|
+
return cov.command !== undefined || cov.browser !== true;
|
|
244
|
+
}
|
|
245
|
+
/** Whether a `coverage` value collects frontend (browser) coverage. */
|
|
246
|
+
export function coverageUsesBrowser(cov) {
|
|
247
|
+
return typeof cov === "object" && cov.browser === true;
|
|
232
248
|
}
|
|
233
249
|
function validateEnvironmentConfig(config) {
|
|
234
250
|
const entries = Object.entries(config.services);
|
package/package.json
CHANGED
|
@@ -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
|
|