@specific.dev/spectest 0.56.2 → 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 +200 -11
- package/dist/harness/browser-coverage.d.ts +95 -0
- package/dist/harness/browser-coverage.js +257 -0
- package/dist/harness/coverage.d.ts +60 -0
- package/dist/harness/coverage.js +122 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +32 -0
- package/package.json +1 -1
- package/src/browser-coverage.ts +209 -0
- package/src/browser.ts +36 -0
- package/src/daemon.ts +230 -11
- package/src/harness/browser-coverage.test.ts +94 -0
- package/src/harness/browser-coverage.ts +310 -0
- package/src/harness/coverage.test.ts +88 -0
- package/src/harness/coverage.ts +173 -0
- package/src/index.ts +81 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CDPSession } from "playwright-core";
|
|
2
|
+
export interface BrowserCoverageConfig {
|
|
3
|
+
/** Which covered service (if any) serves scripts from this host. */
|
|
4
|
+
serviceForHost(host: string): string | undefined;
|
|
5
|
+
}
|
|
6
|
+
/** Set (or clear, with `null`) at /load. */
|
|
7
|
+
export declare function configureBrowserCoverage(config: BrowserCoverageConfig | null): void;
|
|
8
|
+
export declare function browserCoverageActive(): boolean;
|
|
9
|
+
/** Turn coverage on for a page's CDP session. Idempotent; a no-op when no
|
|
10
|
+
* service opted in. */
|
|
11
|
+
export declare function attachBrowserCoverage(cdp: CDPSession): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Take the session's counters and fold them into the totals. Concurrent
|
|
14
|
+
* calls coalesce onto the in-flight one. Errors (a page mid-navigation,
|
|
15
|
+
* a closed view) are swallowed: the counters then stay in V8 for the next
|
|
16
|
+
* take, nothing is lost.
|
|
17
|
+
*/
|
|
18
|
+
export declare function harvestBrowserCoverage(cdp: CDPSession): Promise<void>;
|
|
19
|
+
/** One lcov document per service that has any browser coverage. */
|
|
20
|
+
export declare function browserCoverageReports(): Map<string, string>;
|
|
@@ -0,0 +1,185 @@
|
|
|
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
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { decodeSourceMap, lcovDocument, mapToOriginal, mergeFileCounts, rangesToLineCounts, } from "./harness/browser-coverage.js";
|
|
23
|
+
import { rawFetch } from "./harness/raw-fetch.js";
|
|
24
|
+
let CONFIG = null;
|
|
25
|
+
/** Set (or clear, with `null`) at /load. */
|
|
26
|
+
export function configureBrowserCoverage(config) {
|
|
27
|
+
CONFIG = config;
|
|
28
|
+
}
|
|
29
|
+
export function browserCoverageActive() {
|
|
30
|
+
return CONFIG !== null;
|
|
31
|
+
}
|
|
32
|
+
/** service → file → line → count, summed over every harvest. */
|
|
33
|
+
const TOTALS = new Map();
|
|
34
|
+
const COLLECTORS = new WeakMap();
|
|
35
|
+
/** Decoded maps by URL (or content hash for inline `data:` maps). */
|
|
36
|
+
const SOURCE_MAPS = new Map();
|
|
37
|
+
const SOURCE_MAP_FETCH_TIMEOUT_MS = 10_000;
|
|
38
|
+
/** Turn coverage on for a page's CDP session. Idempotent; a no-op when no
|
|
39
|
+
* service opted in. */
|
|
40
|
+
export async function attachBrowserCoverage(cdp) {
|
|
41
|
+
if (!CONFIG || COLLECTORS.has(cdp))
|
|
42
|
+
return;
|
|
43
|
+
const collector = { cdp, scripts: new Map(), inFlight: null };
|
|
44
|
+
COLLECTORS.set(cdp, collector);
|
|
45
|
+
cdp.on("Debugger.scriptParsed", (ev) => {
|
|
46
|
+
if (!ev.url)
|
|
47
|
+
return;
|
|
48
|
+
collector.scripts.set(ev.scriptId, {
|
|
49
|
+
url: ev.url,
|
|
50
|
+
sourceMapURL: ev.sourceMapURL || undefined,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
await cdp.send("Debugger.enable");
|
|
54
|
+
await cdp.send("Profiler.enable");
|
|
55
|
+
await cdp.send("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Take the session's counters and fold them into the totals. Concurrent
|
|
59
|
+
* calls coalesce onto the in-flight one. Errors (a page mid-navigation,
|
|
60
|
+
* a closed view) are swallowed: the counters then stay in V8 for the next
|
|
61
|
+
* take, nothing is lost.
|
|
62
|
+
*/
|
|
63
|
+
export async function harvestBrowserCoverage(cdp) {
|
|
64
|
+
const c = COLLECTORS.get(cdp);
|
|
65
|
+
if (!c || !CONFIG)
|
|
66
|
+
return;
|
|
67
|
+
if (c.inFlight)
|
|
68
|
+
return c.inFlight;
|
|
69
|
+
c.inFlight = harvestInner(c, CONFIG).catch(() => { }).finally(() => {
|
|
70
|
+
c.inFlight = null;
|
|
71
|
+
});
|
|
72
|
+
return c.inFlight;
|
|
73
|
+
}
|
|
74
|
+
async function harvestInner(c, config) {
|
|
75
|
+
const { result } = (await c.cdp.send("Profiler.takePreciseCoverage"));
|
|
76
|
+
for (const script of result) {
|
|
77
|
+
const info = c.scripts.get(script.scriptId) ?? (script.url ? { url: script.url } : undefined);
|
|
78
|
+
if (!info)
|
|
79
|
+
continue;
|
|
80
|
+
let url;
|
|
81
|
+
try {
|
|
82
|
+
url = new URL(info.url);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
88
|
+
continue;
|
|
89
|
+
const service = config.serviceForHost(url.hostname);
|
|
90
|
+
if (!service)
|
|
91
|
+
continue;
|
|
92
|
+
if (info.source === undefined) {
|
|
93
|
+
try {
|
|
94
|
+
const r = (await c.cdp.send("Debugger.getScriptSource", {
|
|
95
|
+
scriptId: script.scriptId,
|
|
96
|
+
}));
|
|
97
|
+
info.source = r.scriptSource;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
continue; // script gone with its document
|
|
101
|
+
}
|
|
102
|
+
c.scripts.set(script.scriptId, info);
|
|
103
|
+
}
|
|
104
|
+
const generated = rangesToLineCounts(info.source, script.functions);
|
|
105
|
+
if (generated.size === 0)
|
|
106
|
+
continue;
|
|
107
|
+
if (info.map === undefined)
|
|
108
|
+
info.map = await sourceMapFor(info, url);
|
|
109
|
+
const files = info.map
|
|
110
|
+
? mapToOriginal(info.map, generated)
|
|
111
|
+
: new Map([[url.pathname, generated]]);
|
|
112
|
+
let totals = TOTALS.get(service);
|
|
113
|
+
if (!totals) {
|
|
114
|
+
totals = new Map();
|
|
115
|
+
TOTALS.set(service, totals);
|
|
116
|
+
}
|
|
117
|
+
mergeFileCounts(totals, files);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Resolve, fetch (or inline-decode) and decode a script's source map. */
|
|
121
|
+
async function sourceMapFor(info, scriptUrl) {
|
|
122
|
+
let ref = info.sourceMapURL;
|
|
123
|
+
if (!ref && info.source) {
|
|
124
|
+
const m = /\/\/[#@]\s*sourceMappingURL=(\S+)\s*$/.exec(info.source.slice(-4096));
|
|
125
|
+
if (m)
|
|
126
|
+
ref = m[1];
|
|
127
|
+
}
|
|
128
|
+
if (!ref)
|
|
129
|
+
return null;
|
|
130
|
+
if (ref.startsWith("data:")) {
|
|
131
|
+
const key = "data:" + createHash("sha1").update(ref).digest("hex");
|
|
132
|
+
const cached = SOURCE_MAPS.get(key);
|
|
133
|
+
if (cached !== undefined)
|
|
134
|
+
return cached;
|
|
135
|
+
const map = decodeSourceMap(parseDataUrl(ref));
|
|
136
|
+
SOURCE_MAPS.set(key, map);
|
|
137
|
+
return map;
|
|
138
|
+
}
|
|
139
|
+
let mapUrl;
|
|
140
|
+
try {
|
|
141
|
+
mapUrl = new URL(ref, scriptUrl).toString();
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const cached = SOURCE_MAPS.get(mapUrl);
|
|
147
|
+
if (cached !== undefined)
|
|
148
|
+
return cached;
|
|
149
|
+
let map = null;
|
|
150
|
+
try {
|
|
151
|
+
const res = await rawFetch(mapUrl, { signal: AbortSignal.timeout(SOURCE_MAP_FETCH_TIMEOUT_MS) });
|
|
152
|
+
if (res.ok)
|
|
153
|
+
map = decodeSourceMap(await res.json());
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
map = null;
|
|
157
|
+
}
|
|
158
|
+
SOURCE_MAPS.set(mapUrl, map);
|
|
159
|
+
return map;
|
|
160
|
+
}
|
|
161
|
+
function parseDataUrl(ref) {
|
|
162
|
+
const comma = ref.indexOf(",");
|
|
163
|
+
if (comma < 0)
|
|
164
|
+
return null;
|
|
165
|
+
const meta = ref.slice(5, comma);
|
|
166
|
+
const payload = ref.slice(comma + 1);
|
|
167
|
+
try {
|
|
168
|
+
const text = /;base64$/i.test(meta)
|
|
169
|
+
? Buffer.from(payload, "base64").toString("utf8")
|
|
170
|
+
: decodeURIComponent(payload);
|
|
171
|
+
return JSON.parse(text);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** One lcov document per service that has any browser coverage. */
|
|
178
|
+
export function browserCoverageReports() {
|
|
179
|
+
const out = new Map();
|
|
180
|
+
for (const [service, files] of TOTALS) {
|
|
181
|
+
if (files.size > 0)
|
|
182
|
+
out.set(service, lcovDocument(files));
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
package/dist/browser.js
CHANGED
|
@@ -35,6 +35,7 @@ import { generateId } from "./ids.js";
|
|
|
35
35
|
import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
36
36
|
import { wrap } from "./inspect.js";
|
|
37
37
|
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
38
|
+
import { attachBrowserCoverage, browserCoverageActive, harvestBrowserCoverage, } from "./browser-coverage.js";
|
|
38
39
|
import { attachBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
|
|
39
40
|
import { chromium } from "playwright-core";
|
|
40
41
|
/** Decoded byte count of a base64 string, without decoding it. */
|
|
@@ -792,6 +793,14 @@ async function spawnPage(context) {
|
|
|
792
793
|
console.warn("[spectest] failed to install rrweb recorder:", err);
|
|
793
794
|
}
|
|
794
795
|
}
|
|
796
|
+
// Frontend coverage (opt-in): turn on V8 precise coverage for this page.
|
|
797
|
+
// A no-op unless a service declared `coverage: { browser: true }`.
|
|
798
|
+
try {
|
|
799
|
+
await attachBrowserCoverage(cdp);
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
/* coverage is best-effort; a page without it still works */
|
|
803
|
+
}
|
|
795
804
|
return { page, cdp, recordingInstalled };
|
|
796
805
|
}
|
|
797
806
|
/** Create a default-desktop view (plus its context) for the pool. */
|
|
@@ -1080,6 +1089,14 @@ async function rebuildView(holder) {
|
|
|
1080
1089
|
catch {
|
|
1081
1090
|
/* page may already be gone */
|
|
1082
1091
|
}
|
|
1092
|
+
// The old renderer's coverage counters die with its page; harvest what
|
|
1093
|
+
// it had before the swap (rebuild is triggered on a fresh navigation).
|
|
1094
|
+
try {
|
|
1095
|
+
await harvestBrowserCoverage(holder.cdp);
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
/* best-effort */
|
|
1099
|
+
}
|
|
1083
1100
|
const fresh = await spawnPage(holder.context);
|
|
1084
1101
|
holder.page = fresh.page;
|
|
1085
1102
|
holder.cdp = fresh.cdp;
|
|
@@ -1164,6 +1181,17 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1164
1181
|
durationMs: endT - t,
|
|
1165
1182
|
}, resv);
|
|
1166
1183
|
await drain(action);
|
|
1184
|
+
// Fold this page's V8 counters into the per-service totals now — a
|
|
1185
|
+
// navigation to another origin replaces the renderer and its
|
|
1186
|
+
// counters, so harvesting per op is what keeps them.
|
|
1187
|
+
if (browserCoverageActive()) {
|
|
1188
|
+
try {
|
|
1189
|
+
await harvestBrowserCoverage(holder.cdp);
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
/* best-effort */
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1167
1195
|
// Reads (`opts.wrap`) return user-visible JS values someone is likely to
|
|
1168
1196
|
// assert on — provenance-wrap so `expect(...)` nests under this step.
|
|
1169
1197
|
// Actions return void/internals; leave them raw to avoid Proxy surprises.
|
|
@@ -1193,6 +1221,14 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1193
1221
|
return;
|
|
1194
1222
|
// Final drain before we stop writing to this recorder.
|
|
1195
1223
|
await drain("close");
|
|
1224
|
+
if (browserCoverageActive()) {
|
|
1225
|
+
try {
|
|
1226
|
+
await harvestBrowserCoverage(holder.cdp);
|
|
1227
|
+
}
|
|
1228
|
+
catch {
|
|
1229
|
+
/* best-effort */
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1196
1232
|
recordingEnded = true;
|
|
1197
1233
|
}
|
|
1198
1234
|
const strategy = holder.device ? mobileStrategy : desktopStrategy;
|
package/dist/daemon.js
CHANGED
|
@@ -21,7 +21,10 @@ import { existsSync, promises as fs, readFileSync } from "node:fs";
|
|
|
21
21
|
import net from "node:net";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
|
-
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
|
|
24
|
+
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, validateCoverage, coverageUsesBrowser, coverageUsesContainer, } from "./index.js";
|
|
25
|
+
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, readCoverageDir, } from "./harness/coverage.js";
|
|
26
|
+
import { browserCoverageReports, configureBrowserCoverage, } from "./browser-coverage.js";
|
|
27
|
+
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
25
28
|
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
26
29
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
27
30
|
// Pure ingress hostname matching, ported out of this file (see
|
|
@@ -510,6 +513,27 @@ async function ensureVolumes(svc) {
|
|
|
510
513
|
}
|
|
511
514
|
return flags;
|
|
512
515
|
}
|
|
516
|
+
/**
|
|
517
|
+
* The coverage directory for an opted-in service: one host dir per service
|
|
518
|
+
* under the workspace (so the delta-restore teardown wipes it), mounted
|
|
519
|
+
* writable at `/spectest/coverage/`. World-writable because the container
|
|
520
|
+
* may run as any user and spectest creates the dir as root. The reports in
|
|
521
|
+
* it ride snapshots and forks like everything else on the rootfs, which is
|
|
522
|
+
* what makes each read cumulative along the test's ancestor chain.
|
|
523
|
+
*/
|
|
524
|
+
async function ensureCoverage(svc) {
|
|
525
|
+
if (svc.coverage === undefined)
|
|
526
|
+
return [];
|
|
527
|
+
validateCoverage(svc.name, svc.coverage);
|
|
528
|
+
// A browser-only opt-in has no container process to write reports, so it
|
|
529
|
+
// gets no mount — its coverage comes from the guest browser.
|
|
530
|
+
if (!coverageUsesContainer(svc.coverage))
|
|
531
|
+
return [];
|
|
532
|
+
const host = coverageHostDir(WORKSPACE, svc.name);
|
|
533
|
+
await fs.mkdir(host, { recursive: true });
|
|
534
|
+
await fs.chmod(host, 0o777);
|
|
535
|
+
return [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
|
|
536
|
+
}
|
|
513
537
|
/** Keyed by image ID, not tag: `spectest/<svc>:latest` is retagged onto
|
|
514
538
|
* new content every rebuild, and a stale uid is silently wrong. */
|
|
515
539
|
const IMAGE_ID_TABLES = new Map();
|
|
@@ -1404,6 +1428,7 @@ function buildIngress(project) {
|
|
|
1404
1428
|
// generic decl set the daemon executes. The special-casing lives in the
|
|
1405
1429
|
// SDK's lowerIngress, not here.
|
|
1406
1430
|
LOWERED = lowerIngress(project);
|
|
1431
|
+
configureCoverageAttribution(project);
|
|
1407
1432
|
if (!project.fakes)
|
|
1408
1433
|
return;
|
|
1409
1434
|
for (const [name, def] of Object.entries(project.fakes)) {
|
|
@@ -2309,6 +2334,7 @@ async function startRuntimeService(spec) {
|
|
|
2309
2334
|
...(await ensureVolumes(svc)),
|
|
2310
2335
|
...(await ensureFiles(svc, tag)),
|
|
2311
2336
|
...(await ensureCertificates(svc, tag)),
|
|
2337
|
+
...(await ensureCoverage(svc)),
|
|
2312
2338
|
];
|
|
2313
2339
|
await runContainer(svc, tag, flags, aliases);
|
|
2314
2340
|
await waitForReady(svc);
|
|
@@ -2707,6 +2733,7 @@ async function bootstrapInner() {
|
|
|
2707
2733
|
...(await ensureVolumes(svc)),
|
|
2708
2734
|
...(await ensureFiles(svc, tag)),
|
|
2709
2735
|
...(await ensureCertificates(svc, tag)),
|
|
2736
|
+
...(await ensureCoverage(svc)),
|
|
2710
2737
|
];
|
|
2711
2738
|
const tRun = Date.now();
|
|
2712
2739
|
progressService(svc.name, { status: "starting", detail: undefined });
|
|
@@ -2827,18 +2854,37 @@ async function runProjectSetupInner() {
|
|
|
2827
2854
|
// bundle in guest RAM — entries older than the last few are always
|
|
2828
2855
|
// already-fetched leftovers frozen into some snapshot.
|
|
2829
2856
|
const REPLAY_BUNDLES = new Map();
|
|
2830
|
-
|
|
2831
|
-
|
|
2857
|
+
/** Coverage bundles, same side-channel and same lifetime rules; pulled
|
|
2858
|
+
* through `coverageChunk`. The env-setup baseline parks under the
|
|
2859
|
+
* control plane's synthetic `__setup__` case id. */
|
|
2860
|
+
const COVERAGE_BUNDLES = new Map();
|
|
2861
|
+
const PARKED_BUNDLES_MAX = 8;
|
|
2862
|
+
/** The control plane's synthetic case id for env-setup captures
|
|
2863
|
+
* (`storage::SETUP_CASE_ID`). Not a real case id — those are kebab slugs. */
|
|
2864
|
+
const SETUP_CASE_ID = "__setup__";
|
|
2865
|
+
function stashBundle(map, caseId, gz) {
|
|
2832
2866
|
// Re-insert to refresh recency (Map iterates in insertion order).
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
while (
|
|
2836
|
-
const oldest =
|
|
2867
|
+
map.delete(caseId);
|
|
2868
|
+
map.set(caseId, gz);
|
|
2869
|
+
while (map.size > PARKED_BUNDLES_MAX) {
|
|
2870
|
+
const oldest = map.keys().next().value;
|
|
2837
2871
|
if (oldest === undefined)
|
|
2838
2872
|
break;
|
|
2839
|
-
|
|
2873
|
+
map.delete(oldest);
|
|
2840
2874
|
}
|
|
2841
2875
|
}
|
|
2876
|
+
function stashReplayBundle(caseId, gz) {
|
|
2877
|
+
stashBundle(REPLAY_BUNDLES, caseId, gz);
|
|
2878
|
+
}
|
|
2879
|
+
/** Encode + park a case's coverage captures; returns the ref for the reply.
|
|
2880
|
+
* `undefined` when no service opted in, so uncovered projects ship nothing. */
|
|
2881
|
+
function parkCoverageBundle(caseId, captures) {
|
|
2882
|
+
if (captures.length === 0)
|
|
2883
|
+
return undefined;
|
|
2884
|
+
const gz = encodeCoverageBundle(caseId, captures);
|
|
2885
|
+
stashBundle(COVERAGE_BUNDLES, caseId, gz);
|
|
2886
|
+
return coverageBundleRef(gz, captures);
|
|
2887
|
+
}
|
|
2842
2888
|
// ────────────────────────────────────────────────────────────────────────
|
|
2843
2889
|
// Cumulative service logs (per-case deltas → S3, reconstructed on the web)
|
|
2844
2890
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -2930,6 +2976,97 @@ async function captureServiceLogDeltas() {
|
|
|
2930
2976
|
};
|
|
2931
2977
|
}));
|
|
2932
2978
|
}
|
|
2979
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
2980
|
+
// Coverage capture (opt-in per service; see harness/coverage.ts)
|
|
2981
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
2982
|
+
//
|
|
2983
|
+
// Read after bring-up and after every test, on every case. The reports are
|
|
2984
|
+
// cumulative from process start and live on the rootfs, so what a child
|
|
2985
|
+
// fork reads already includes everything its ancestors ran — no markers,
|
|
2986
|
+
// no deltas (unlike the service logs above). A `command` runs in the
|
|
2987
|
+
// container first so the service writes a fresh report. The reports ship
|
|
2988
|
+
// verbatim; the server derives what it needs from the stored bytes.
|
|
2989
|
+
const COVERAGE_COMMAND_TIMEOUT_MS = 60_000;
|
|
2990
|
+
/** Set the browser-coverage host→service attribution from the loaded
|
|
2991
|
+
* project: only services that opted in to `coverage: { browser: true }`
|
|
2992
|
+
* are attributed, so a script from an uncovered service is ignored. Null
|
|
2993
|
+
* when no service opted in — coverage then attaches nothing. Called on
|
|
2994
|
+
* every /load. */
|
|
2995
|
+
function configureCoverageAttribution(project) {
|
|
2996
|
+
const env = project.environment;
|
|
2997
|
+
const browserServices = new Set();
|
|
2998
|
+
for (const svc of namedServices(env)) {
|
|
2999
|
+
if (coverageUsesBrowser(svc.coverage))
|
|
3000
|
+
browserServices.add(svc.name);
|
|
3001
|
+
}
|
|
3002
|
+
if (browserServices.size === 0) {
|
|
3003
|
+
configureBrowserCoverage(null);
|
|
3004
|
+
return;
|
|
3005
|
+
}
|
|
3006
|
+
const all = namedServices(env).map((s) => s.name);
|
|
3007
|
+
const table = {
|
|
3008
|
+
proxies: LOWERED.proxies,
|
|
3009
|
+
aliasesByService: LOWERED.aliasesByService,
|
|
3010
|
+
wildcards: LOWERED.wildcards,
|
|
3011
|
+
};
|
|
3012
|
+
configureBrowserCoverage({
|
|
3013
|
+
serviceForHost(host) {
|
|
3014
|
+
const svc = hostToService(host, all, table);
|
|
3015
|
+
return svc && browserServices.has(svc) ? svc : undefined;
|
|
3016
|
+
},
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
async function captureServiceCoverage() {
|
|
3020
|
+
const l = loaded;
|
|
3021
|
+
if (!l)
|
|
3022
|
+
return [];
|
|
3023
|
+
const byName = new Map();
|
|
3024
|
+
for (const s of namedServices(l.project.environment))
|
|
3025
|
+
byName.set(s.name, s);
|
|
3026
|
+
for (const [name, s] of RUNTIME_SERVICES)
|
|
3027
|
+
byName.set(name, s);
|
|
3028
|
+
const services = [...byName.values()].filter((s) => s.coverage !== undefined);
|
|
3029
|
+
// Browser coverage is per-service lcov, accumulated in module memory
|
|
3030
|
+
// (browser-coverage.ts) across every harvest; read it once here.
|
|
3031
|
+
const browser = browserCoverageReports();
|
|
3032
|
+
return Promise.all(services.map(async (svc) => {
|
|
3033
|
+
const cov = svc.coverage;
|
|
3034
|
+
const reports = [];
|
|
3035
|
+
// Container reports (a `command` runs first to flush a fresh dump).
|
|
3036
|
+
if (coverageUsesContainer(cov)) {
|
|
3037
|
+
if (typeof cov === "object" && cov !== null && cov.command) {
|
|
3038
|
+
const r = await docker(["exec", svc.name, "sh", "-c", cov.command], COVERAGE_COMMAND_TIMEOUT_MS);
|
|
3039
|
+
if (r.code !== 0) {
|
|
3040
|
+
const tail = capMiddle((r.stderr || r.stdout).trim(), 2000).value;
|
|
3041
|
+
return {
|
|
3042
|
+
service: svc.name,
|
|
3043
|
+
reports: [],
|
|
3044
|
+
error: `coverage command for service "${svc.name}" failed (exit ${r.code}): ${JSON.stringify(cov.command)}${tail ? `\n${tail}` : ""}`,
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
const dir = await readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
|
|
3049
|
+
// A service that ALSO does browser coverage may legitimately have
|
|
3050
|
+
// an empty container dir for a test — don't fail if the browser
|
|
3051
|
+
// side has something. Otherwise the container contract stands.
|
|
3052
|
+
if (dir.error && !(coverageUsesBrowser(cov) && browser.has(svc.name))) {
|
|
3053
|
+
return dir;
|
|
3054
|
+
}
|
|
3055
|
+
reports.push(...dir.reports);
|
|
3056
|
+
}
|
|
3057
|
+
// Browser report for this service, when any script it served ran.
|
|
3058
|
+
const lcov = browser.get(svc.name);
|
|
3059
|
+
if (lcov)
|
|
3060
|
+
reports.push({ name: "browser.lcov", content: lcov });
|
|
3061
|
+
if (reports.length === 0 && coverageUsesBrowser(cov) && !coverageUsesContainer(cov)) {
|
|
3062
|
+
// Browser-only service, no browser activity yet this branch. Not an
|
|
3063
|
+
// error — a test needn't open a browser; the report appears once
|
|
3064
|
+
// one does (and forks to descendants from there).
|
|
3065
|
+
return { service: svc.name, reports: [] };
|
|
3066
|
+
}
|
|
3067
|
+
return { service: svc.name, reports };
|
|
3068
|
+
}));
|
|
3069
|
+
}
|
|
2933
3070
|
/**
|
|
2934
3071
|
* Mint a session id. `idScope` (the running test's case id; `"eval"`
|
|
2935
3072
|
* for eval-context sessions) is baked in because `randomUUID()` alone
|
|
@@ -4128,6 +4265,33 @@ async function runOne(testCase) {
|
|
|
4128
4265
|
// eslint-disable-next-line no-console
|
|
4129
4266
|
console.warn("[service-logs] delta capture failed:", err);
|
|
4130
4267
|
}
|
|
4268
|
+
// Coverage for the opted-in services, read after the clock stops like
|
|
4269
|
+
// the log deltas. Unlike them it is NOT best-effort: a service that opted
|
|
4270
|
+
// in and produced nothing readable fails the case, so a hole in the
|
|
4271
|
+
// coverage data is visible where it happened rather than silently
|
|
4272
|
+
// widening every later subset run.
|
|
4273
|
+
let coverageCaptures = [];
|
|
4274
|
+
try {
|
|
4275
|
+
coverageCaptures = await captureServiceCoverage();
|
|
4276
|
+
}
|
|
4277
|
+
catch (err) {
|
|
4278
|
+
coverageCaptures = [
|
|
4279
|
+
{
|
|
4280
|
+
service: "*",
|
|
4281
|
+
reports: [],
|
|
4282
|
+
error: `coverage capture failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
4283
|
+
},
|
|
4284
|
+
];
|
|
4285
|
+
}
|
|
4286
|
+
const coverageErrors = coverageCaptures
|
|
4287
|
+
.filter((c) => c.error)
|
|
4288
|
+
.map((c) => c.error);
|
|
4289
|
+
if (coverageErrors.length > 0 && outcome.status === "passed") {
|
|
4290
|
+
outcome = {
|
|
4291
|
+
status: "failed",
|
|
4292
|
+
error: { message: coverageErrors.join("\n") },
|
|
4293
|
+
};
|
|
4294
|
+
}
|
|
4131
4295
|
const events = stopRecording();
|
|
4132
4296
|
// Drop sessions whose linking event didn't survive — an exec/terminal
|
|
4133
4297
|
// inside a failed `ctx.poll` iteration has its events removed by
|
|
@@ -4147,6 +4311,7 @@ async function runOne(testCase) {
|
|
|
4147
4311
|
browserSessions: sessions.map((s) => s.record),
|
|
4148
4312
|
terminalSessions: terminalSessions.filter((s) => referencedSessions.has(s.sessionId)),
|
|
4149
4313
|
serviceLogDeltas,
|
|
4314
|
+
coverageCaptures,
|
|
4150
4315
|
error: outcome.error,
|
|
4151
4316
|
};
|
|
4152
4317
|
}
|
|
@@ -4898,7 +5063,20 @@ export function harnessMethods(state) {
|
|
|
4898
5063
|
// eslint-disable-next-line no-console
|
|
4899
5064
|
console.warn("[service-logs] baseline capture failed:", err);
|
|
4900
5065
|
}
|
|
4901
|
-
|
|
5066
|
+
// The coverage baseline rides the same call: what the opted-in
|
|
5067
|
+
// services ran during bring-up + project setup, parked under the
|
|
5068
|
+
// synthetic `__setup__` case for `coverageChunk`. A capture error is
|
|
5069
|
+
// reported in the ref, not thrown — there is no case to fail here,
|
|
5070
|
+
// the control plane logs it.
|
|
5071
|
+
let coverage;
|
|
5072
|
+
try {
|
|
5073
|
+
coverage = parkCoverageBundle(SETUP_CASE_ID, await captureServiceCoverage());
|
|
5074
|
+
}
|
|
5075
|
+
catch (err) {
|
|
5076
|
+
// eslint-disable-next-line no-console
|
|
5077
|
+
console.warn("[coverage] baseline capture failed:", err);
|
|
5078
|
+
}
|
|
5079
|
+
return { serviceLogDeltas, coverage };
|
|
4902
5080
|
},
|
|
4903
5081
|
run: async (params) => {
|
|
4904
5082
|
const caseId = requireString(params, "caseId", "caseId");
|
|
@@ -4912,7 +5090,10 @@ export function harnessMethods(state) {
|
|
|
4912
5090
|
// (with a warning) rather than failing the case — and never falls
|
|
4913
5091
|
// back to inlining, which is exactly the oversized reply this path
|
|
4914
5092
|
// exists to avoid.
|
|
4915
|
-
const { browserSessions, ...wire } = result;
|
|
5093
|
+
const { browserSessions, coverageCaptures, ...wire } = result;
|
|
5094
|
+
// Coverage reports take the same side channel: park, reply with a
|
|
5095
|
+
// ref, let the control plane pull through `coverageChunk`.
|
|
5096
|
+
const coverage = parkCoverageBundle(caseId, coverageCaptures);
|
|
4916
5097
|
let replay;
|
|
4917
5098
|
if (browserSessions.length > 0) {
|
|
4918
5099
|
try {
|
|
@@ -4925,7 +5106,7 @@ export function harnessMethods(state) {
|
|
|
4925
5106
|
console.warn("[replay] bundle encode failed; dropping recordings:", err);
|
|
4926
5107
|
}
|
|
4927
5108
|
}
|
|
4928
|
-
return { ...wire, replay };
|
|
5109
|
+
return { ...wire, replay, coverage };
|
|
4929
5110
|
});
|
|
4930
5111
|
},
|
|
4931
5112
|
// One chunk of a parked replay bundle. Case ids are arbitrary user
|
|
@@ -4937,6 +5118,14 @@ export function harnessMethods(state) {
|
|
|
4937
5118
|
throw notFound(`no replay bundle for case: ${caseId}`);
|
|
4938
5119
|
return replayChunk(gz, params.offset, params.limit);
|
|
4939
5120
|
},
|
|
5121
|
+
// One chunk of a parked coverage bundle (same shape as replayChunk).
|
|
5122
|
+
coverageChunk: async (params) => {
|
|
5123
|
+
const caseId = requireString(params, "caseId", "caseId");
|
|
5124
|
+
const gz = COVERAGE_BUNDLES.get(caseId);
|
|
5125
|
+
if (!gz)
|
|
5126
|
+
throw notFound(`no coverage bundle for case: ${caseId}`);
|
|
5127
|
+
return replayChunk(gz, params.offset, params.limit);
|
|
5128
|
+
},
|
|
4940
5129
|
};
|
|
4941
5130
|
}
|
|
4942
5131
|
/**
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Per-line hit counts, 1-based line numbers. */
|
|
2
|
+
export type LineCounts = Map<number, number>;
|
|
3
|
+
/** Per-file line counts. Keys are source paths as the source map (or the
|
|
4
|
+
* script URL, when there is no map) reports them. */
|
|
5
|
+
export type FileCounts = Map<string, LineCounts>;
|
|
6
|
+
/** Byte offset at which each line starts. */
|
|
7
|
+
export declare function lineStartsOf(source: string): number[];
|
|
8
|
+
/** 1-based line holding `offset`. */
|
|
9
|
+
export declare function lineOf(starts: number[], offset: number): number;
|
|
10
|
+
/** One V8 `FunctionCoverage` as `Profiler.takePreciseCoverage` reports it. */
|
|
11
|
+
export interface V8FunctionCoverage {
|
|
12
|
+
functionName?: string;
|
|
13
|
+
ranges: {
|
|
14
|
+
startOffset: number;
|
|
15
|
+
endOffset: number;
|
|
16
|
+
count: number;
|
|
17
|
+
}[];
|
|
18
|
+
isBlockCoverage?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Fold a script's block ranges onto its lines. Ranges come outer-first
|
|
22
|
+
* and a narrower range overrides the lines it spans, so a line inside an
|
|
23
|
+
* unexecuted branch ends at 0 even though its enclosing function ran.
|
|
24
|
+
* Blank lines are dropped. Approximate — a line holding two blocks gets
|
|
25
|
+
* the narrower one's count — which is exact at the file level, the only
|
|
26
|
+
* level anything reads today.
|
|
27
|
+
*/
|
|
28
|
+
export declare function rangesToLineCounts(source: string, functions: readonly V8FunctionCoverage[]): LineCounts;
|
|
29
|
+
/** One mapping segment: generated column → original (source, line). */
|
|
30
|
+
export interface Segment {
|
|
31
|
+
col: number;
|
|
32
|
+
src: number;
|
|
33
|
+
line: number;
|
|
34
|
+
}
|
|
35
|
+
export interface DecodedSourceMap {
|
|
36
|
+
/** Normalised source paths, indexed by segment `src`. */
|
|
37
|
+
sources: string[];
|
|
38
|
+
/** Segments per generated line (0-based), sorted by column. */
|
|
39
|
+
lines: Segment[][];
|
|
40
|
+
}
|
|
41
|
+
/** Decode one VLQ-encoded segment into its fields. */
|
|
42
|
+
export declare function decodeVlq(segment: string): number[];
|
|
43
|
+
/**
|
|
44
|
+
* Normalise a source-map `sources` entry to a plain path. Bundlers prefix
|
|
45
|
+
* their own scheme (`webpack:///./src/a.ts`), dev servers serve absolute
|
|
46
|
+
* URLs paths (`/src/a.ts`), builds write relative ones (`../src/a.ts`).
|
|
47
|
+
* The server maps these onto the repo by suffix, so all that matters here
|
|
48
|
+
* is stripping what is not a path.
|
|
49
|
+
*/
|
|
50
|
+
export declare function normalizeSourcePath(src: string, sourceRoot?: string): string;
|
|
51
|
+
/** Sources nobody's change can be attributed to: dependencies and bundler
|
|
52
|
+
* runtime shims. */
|
|
53
|
+
export declare function isForeignSource(path: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Decode a source map document. Index maps (`sections`) are not
|
|
56
|
+
* supported and decode to `null`, as does anything malformed — the
|
|
57
|
+
* caller then falls back to attributing the script to its own URL, which
|
|
58
|
+
* the repo mapping cannot resolve, which is the safe answer.
|
|
59
|
+
*/
|
|
60
|
+
export declare function decodeSourceMap(doc: unknown): DecodedSourceMap | null;
|
|
61
|
+
/**
|
|
62
|
+
* Attribute generated line counts to original (file, line)s through the
|
|
63
|
+
* map. Every original line a generated line's segments name gets that
|
|
64
|
+
* line's count (max over the generated lines that touch it). Foreign
|
|
65
|
+
* sources are dropped.
|
|
66
|
+
*/
|
|
67
|
+
export declare function mapToOriginal(map: DecodedSourceMap, generated: LineCounts): FileCounts;
|
|
68
|
+
/** Add `from` into `into` (sums — takes reset V8's counters). */
|
|
69
|
+
export declare function mergeLineCounts(into: LineCounts, from: LineCounts): void;
|
|
70
|
+
export declare function mergeFileCounts(into: FileCounts, from: FileCounts): void;
|
|
71
|
+
/** One lcov document for a set of files. */
|
|
72
|
+
export declare function lcovDocument(files: FileCounts): string;
|
|
73
|
+
/** The slice of the lowered ingress that names hosts. */
|
|
74
|
+
export interface HostTable {
|
|
75
|
+
proxies: readonly {
|
|
76
|
+
hostname: string;
|
|
77
|
+
service: string;
|
|
78
|
+
}[];
|
|
79
|
+
aliasesByService: Readonly<Record<string, readonly string[]>>;
|
|
80
|
+
wildcards: readonly {
|
|
81
|
+
pattern: string;
|
|
82
|
+
target: {
|
|
83
|
+
service?: string;
|
|
84
|
+
} | {
|
|
85
|
+
ingress?: boolean;
|
|
86
|
+
} | Record<string, unknown>;
|
|
87
|
+
}[];
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Which service a script URL's host belongs to: the service key itself,
|
|
91
|
+
* `<key>.internal`, its `hostnames`/`dnsName` aliases, a `tls`/`proxy`
|
|
92
|
+
* hostname routed to it, or a wildcard pointed at it. Exact first, then
|
|
93
|
+
* the longest wildcard suffix — the resolver's own precedence.
|
|
94
|
+
*/
|
|
95
|
+
export declare function serviceForHost(host: string, services: readonly string[], table: HostTable): string | undefined;
|