@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
|
@@ -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;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ServiceConfig } from "./index.js";
|
|
2
|
+
import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
3
|
+
export { COVERAGE_CONTAINER_DIR };
|
|
4
|
+
/** Where `node()` mounts its hook inside the container. */
|
|
5
|
+
export declare const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
6
|
+
/** The socket the node hook answers on, relative to the coverage dir. */
|
|
7
|
+
export declare const NODE_COVERAGE_SOCKET = ".ctl";
|
|
8
|
+
/** What `configure` learns about the service it rewrites. */
|
|
9
|
+
export interface CoverageConfigureInfo {
|
|
10
|
+
/** The services-map key. */
|
|
11
|
+
key: string;
|
|
12
|
+
}
|
|
13
|
+
/** The harness capabilities an adapter can ask for at `/load`. */
|
|
14
|
+
export interface CoverageLoadContext {
|
|
15
|
+
/** The services-map key. */
|
|
16
|
+
service: string;
|
|
17
|
+
/**
|
|
18
|
+
* Collect V8 coverage for every script the guest browser
|
|
19
|
+
* (`ctx.browser()` / `ctx.mobile()`) loads from this service's origins:
|
|
20
|
+
* its key, `<key>.internal`, its `hostnames`/`dnsName` aliases, its
|
|
21
|
+
* `tls`/`proxy` hostnames and any service-targeted wildcard. The
|
|
22
|
+
* harness maps each script back to source through the served source
|
|
23
|
+
* map and keeps per-service totals in memory (they fork with the
|
|
24
|
+
* environment). `browser()` calls this and writes the totals
|
|
25
|
+
* at capture.
|
|
26
|
+
*/
|
|
27
|
+
collectBrowserScripts(): void;
|
|
28
|
+
}
|
|
29
|
+
/** What one adapter's `capture` runs against. */
|
|
30
|
+
export interface CoverageCaptureContext {
|
|
31
|
+
/** The services-map key. */
|
|
32
|
+
service: string;
|
|
33
|
+
/**
|
|
34
|
+
* The coverage directory, seen from the harness. It is the same
|
|
35
|
+
* directory the container sees at `/spectest/coverage/` (a bind
|
|
36
|
+
* mount), so a file written here is in the container and a socket the
|
|
37
|
+
* container bound here is reachable.
|
|
38
|
+
*/
|
|
39
|
+
reportDir: string;
|
|
40
|
+
/** Run a shell command (`sh -c`) inside the container. Rejects on a
|
|
41
|
+
* non-zero exit with the command's output in the message. */
|
|
42
|
+
exec(command: string): Promise<{
|
|
43
|
+
stdout: string;
|
|
44
|
+
stderr: string;
|
|
45
|
+
}>;
|
|
46
|
+
/** Write one report file (name relative to the directory). */
|
|
47
|
+
writeReport(name: string, content: string): Promise<void>;
|
|
48
|
+
/** Aborts when the per-service capture budget runs out. */
|
|
49
|
+
signal: AbortSignal;
|
|
50
|
+
}
|
|
51
|
+
export interface CoverageAdapter {
|
|
52
|
+
/** Names the adapter in errors (`coverage adapter "node" on service …`). */
|
|
53
|
+
name: string;
|
|
54
|
+
/**
|
|
55
|
+
* Rewrite the service at config time. Must be pure and deterministic
|
|
56
|
+
* (the result is hashed into the warm-template key) and must **append**
|
|
57
|
+
* to `env` values like `NODE_OPTIONS` rather than replace them.
|
|
58
|
+
*/
|
|
59
|
+
configure?(service: ServiceConfig, info: CoverageConfigureInfo): ServiceConfig;
|
|
60
|
+
/** Ask the harness for capabilities, at `/load`. */
|
|
61
|
+
load?(ctx: CoverageLoadContext): void | Promise<void>;
|
|
62
|
+
/** Make the report appear in the directory. A throw fails the test. */
|
|
63
|
+
capture?(ctx: CoverageCaptureContext): void | Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
/** `ServiceConfig.coverage`: the program writes its own reports, or a
|
|
66
|
+
* list of adapters gets them out. */
|
|
67
|
+
export type ServiceCoverage = true | {
|
|
68
|
+
adapters: readonly CoverageAdapter[];
|
|
69
|
+
};
|
|
70
|
+
/** Type an inline adapter. Identity at runtime. */
|
|
71
|
+
export declare function defineAdapter(adapter: CoverageAdapter): CoverageAdapter;
|
|
72
|
+
/** Check one service's `coverage` field. Exported so the daemon applies
|
|
73
|
+
* the same rule to a runtime `startService` spec. */
|
|
74
|
+
export declare function validateCoverage(service: string, cov: unknown): void;
|
|
75
|
+
/** The adapters of a `coverage` value (`true` has none). */
|
|
76
|
+
export declare function coverageAdapters(cov: ServiceCoverage | undefined): readonly CoverageAdapter[];
|
|
77
|
+
/**
|
|
78
|
+
* Apply every adapter's `configure` to a service, in list order. Called by
|
|
79
|
+
* `defineEnvironment` (after group expansion, before validation) and by
|
|
80
|
+
* the daemon for a runtime `startService` spec. The adapter list stays on
|
|
81
|
+
* the returned config for the harness; on the wire it serializes as the
|
|
82
|
+
* adapter names (`toJSON`), never as functions.
|
|
83
|
+
*/
|
|
84
|
+
export declare function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc: S): S;
|
|
85
|
+
/** `env` with `value` appended to `name` (space-separated), or set. */
|
|
86
|
+
export declare function appendEnvFlag(env: Readonly<Record<string, string>> | undefined, name: string, value: string): Record<string, string>;
|
|
87
|
+
/**
|
|
88
|
+
* The hook `node()` mounts and `--require`s into every node
|
|
89
|
+
* process of the container. Node writes V8 coverage JSON into
|
|
90
|
+
* `NODE_V8_COVERAGE` when a process exits; a long-lived server never
|
|
91
|
+
* exits, so the hook binds a Unix socket in the coverage directory and
|
|
92
|
+
* calls `v8.takeCoverage()` for each connection. The first process to
|
|
93
|
+
* start owns the socket (a stale socket left by a dead process is
|
|
94
|
+
* reclaimed); a later process — a CLI run by `ctx.exec` — leaves it
|
|
95
|
+
* alone and writes at its own exit. No signal is used: signals are
|
|
96
|
+
* claimed by frameworks (SIGUSR2 stops a Temporal worker, restarts
|
|
97
|
+
* nodemon), a socket is nobody's.
|
|
98
|
+
*/
|
|
99
|
+
export declare const NODE_COVERAGE_HOOK: string;
|
|
100
|
+
/**
|
|
101
|
+
* Coverage for a Node service. Sets `NODE_V8_COVERAGE` to the coverage
|
|
102
|
+
* directory (every node process in the container then writes V8
|
|
103
|
+
* coverage JSON when it exits) and `--require`s a hook that lets spectest
|
|
104
|
+
* ask the long-lived server process for a dump at capture time. Nothing
|
|
105
|
+
* for the app to write. Source maps: with `--enable-source-maps` (or a
|
|
106
|
+
* `sourceMappingURL` next to the file) Node records the map in the
|
|
107
|
+
* report, which is what maps a TypeScript service back to its sources.
|
|
108
|
+
*/
|
|
109
|
+
export declare function node(): CoverageAdapter;
|
|
110
|
+
/**
|
|
111
|
+
* Coverage for the frontend a service serves. The code runs in the guest
|
|
112
|
+
* browser — spectest's own process — so no report can be written by the
|
|
113
|
+
* container; spectest collects V8 coverage for every script the browser
|
|
114
|
+
* loads from this service's origins, maps it back to source through the
|
|
115
|
+
* served source map, and writes one lcov per capture. Nothing ran yet on
|
|
116
|
+
* this branch ⇒ an lcov with no records, which is a report saying so.
|
|
117
|
+
*/
|
|
118
|
+
export declare function browser(): CoverageAdapter;
|
|
119
|
+
/**
|
|
120
|
+
* Run `command` (via `sh -c`) in the container at every capture, so the
|
|
121
|
+
* service writes a fresh report into `/spectest/coverage/`. The escape
|
|
122
|
+
* hatch for a runtime with no shipped adapter.
|
|
123
|
+
*/
|
|
124
|
+
export declare function command(command: string): CoverageAdapter;
|
package/dist/coverage.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Coverage adapters — how spectest gets a coverage report out of a service.
|
|
2
|
+
//
|
|
3
|
+
// A service opts in with `coverage: true` (the program writes its own
|
|
4
|
+
// reports into `/spectest/coverage/`) or `coverage: { adapters: [...] }`
|
|
5
|
+
// (spectest gets the reports out). An adapter has three optional moments:
|
|
6
|
+
//
|
|
7
|
+
// configure — config time, inside `defineEnvironment`. Rewrites the
|
|
8
|
+
// service (env, files, command). Pure: its output is part
|
|
9
|
+
// of the wire config and of the warm-template hash.
|
|
10
|
+
// load — harness, at /load. Asks the harness for capabilities a
|
|
11
|
+
// container cannot supply itself (browser scripts).
|
|
12
|
+
// capture — harness, after bring-up and after every test. Must leave
|
|
13
|
+
// a report in the directory.
|
|
14
|
+
//
|
|
15
|
+
// After every adapter ran, the harness reads the directory the same way
|
|
16
|
+
// it does for `coverage: true`: at least one well-formed report (lcov or
|
|
17
|
+
// V8 JSON), shipped verbatim. Adapters convert nothing — the control
|
|
18
|
+
// plane derives what it needs from the stored bytes.
|
|
19
|
+
//
|
|
20
|
+
// Imported from `@specific.dev/spectest/coverage`. The three shipped
|
|
21
|
+
// adapters: `node()` (V8 JSON through a hook spectest mounts), `browser()`
|
|
22
|
+
// (scripts the guest browser loads from the service), `command(cmd)` (run
|
|
23
|
+
// a command, then read). A project writes its own with `defineAdapter`.
|
|
24
|
+
//
|
|
25
|
+
// import * as coverage from "@specific.dev/spectest/coverage";
|
|
26
|
+
// coverage: { adapters: [coverage.node(), coverage.browser()] }
|
|
27
|
+
import { browserCoverageReports } from "./browser-coverage.js";
|
|
28
|
+
import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
29
|
+
export { COVERAGE_CONTAINER_DIR };
|
|
30
|
+
/** Where `node()` mounts its hook inside the container. */
|
|
31
|
+
export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
32
|
+
/** The socket the node hook answers on, relative to the coverage dir. */
|
|
33
|
+
export const NODE_COVERAGE_SOCKET = ".ctl";
|
|
34
|
+
/** Type an inline adapter. Identity at runtime. */
|
|
35
|
+
export function defineAdapter(adapter) {
|
|
36
|
+
return adapter;
|
|
37
|
+
}
|
|
38
|
+
/** Check one service's `coverage` field. Exported so the daemon applies
|
|
39
|
+
* the same rule to a runtime `startService` spec. */
|
|
40
|
+
export function validateCoverage(service, cov) {
|
|
41
|
+
if (cov === undefined || cov === true)
|
|
42
|
+
return;
|
|
43
|
+
const adapters = cov?.adapters;
|
|
44
|
+
if (typeof cov === "object" && cov !== null && Array.isArray(adapters)) {
|
|
45
|
+
for (const a of adapters) {
|
|
46
|
+
const ok = typeof a === "object" &&
|
|
47
|
+
a !== null &&
|
|
48
|
+
typeof a.name === "string" &&
|
|
49
|
+
a.name.length > 0 &&
|
|
50
|
+
["configure", "load", "capture"].every((k) => a[k] === undefined ||
|
|
51
|
+
typeof a[k] === "function");
|
|
52
|
+
if (!ok) {
|
|
53
|
+
throw new Error(`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, configure?, load?, capture? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`service "${service}" has an invalid \`coverage\` value — use \`true\` (the program writes its own reports) or \`{ adapters: [...] }\` from @specific.dev/spectest/coverage (\`node()\`, \`browser()\`, \`command("…")\`)`);
|
|
59
|
+
}
|
|
60
|
+
/** The adapters of a `coverage` value (`true` has none). */
|
|
61
|
+
export function coverageAdapters(cov) {
|
|
62
|
+
return typeof cov === "object" && cov !== null ? cov.adapters : [];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Apply every adapter's `configure` to a service, in list order. Called by
|
|
66
|
+
* `defineEnvironment` (after group expansion, before validation) and by
|
|
67
|
+
* the daemon for a runtime `startService` spec. The adapter list stays on
|
|
68
|
+
* the returned config for the harness; on the wire it serializes as the
|
|
69
|
+
* adapter names (`toJSON`), never as functions.
|
|
70
|
+
*/
|
|
71
|
+
export function applyCoverageAdapters(key, svc) {
|
|
72
|
+
validateCoverage(key, svc.coverage);
|
|
73
|
+
const adapters = coverageAdapters(svc.coverage);
|
|
74
|
+
if (adapters.length === 0)
|
|
75
|
+
return svc;
|
|
76
|
+
let out = svc;
|
|
77
|
+
for (const a of adapters) {
|
|
78
|
+
if (a.configure)
|
|
79
|
+
out = a.configure(out, { key });
|
|
80
|
+
}
|
|
81
|
+
const wire = { adapters: adapters.map((a) => withWireName(a)) };
|
|
82
|
+
return { ...out, coverage: wire };
|
|
83
|
+
}
|
|
84
|
+
function withWireName(a) {
|
|
85
|
+
if (Object.prototype.hasOwnProperty.call(a, "toJSON"))
|
|
86
|
+
return a;
|
|
87
|
+
return Object.assign(Object.create(Object.getPrototypeOf(a)), a, {
|
|
88
|
+
toJSON: () => ({ adapter: a.name }),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** `env` with `value` appended to `name` (space-separated), or set. */
|
|
92
|
+
export function appendEnvFlag(env, name, value) {
|
|
93
|
+
const prior = env?.[name]?.trim();
|
|
94
|
+
return { ...env, [name]: prior ? `${prior} ${value}` : value };
|
|
95
|
+
}
|
|
96
|
+
// ── node ──────────────────────────────────────────────────────────
|
|
97
|
+
/**
|
|
98
|
+
* The hook `node()` mounts and `--require`s into every node
|
|
99
|
+
* process of the container. Node writes V8 coverage JSON into
|
|
100
|
+
* `NODE_V8_COVERAGE` when a process exits; a long-lived server never
|
|
101
|
+
* exits, so the hook binds a Unix socket in the coverage directory and
|
|
102
|
+
* calls `v8.takeCoverage()` for each connection. The first process to
|
|
103
|
+
* start owns the socket (a stale socket left by a dead process is
|
|
104
|
+
* reclaimed); a later process — a CLI run by `ctx.exec` — leaves it
|
|
105
|
+
* alone and writes at its own exit. No signal is used: signals are
|
|
106
|
+
* claimed by frameworks (SIGUSR2 stops a Temporal worker, restarts
|
|
107
|
+
* nodemon), a socket is nobody's.
|
|
108
|
+
*/
|
|
109
|
+
export const NODE_COVERAGE_HOOK = `"use strict";
|
|
110
|
+
// spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
|
|
111
|
+
if (process.env.NODE_V8_COVERAGE) {
|
|
112
|
+
const net = require("node:net");
|
|
113
|
+
const fs = require("node:fs");
|
|
114
|
+
const v8 = require("node:v8");
|
|
115
|
+
const SOCK = require("node:path").join(process.env.NODE_V8_COVERAGE, ${JSON.stringify(NODE_COVERAGE_SOCKET)});
|
|
116
|
+
const server = net.createServer((conn) => {
|
|
117
|
+
// A dump is taken only on an explicit "dump" request: a bystander's
|
|
118
|
+
// liveness probe connects and closes without one.
|
|
119
|
+
let buf = "";
|
|
120
|
+
conn.on("data", (chunk) => {
|
|
121
|
+
buf += chunk;
|
|
122
|
+
if (!buf.includes("\\n")) return;
|
|
123
|
+
let reply;
|
|
124
|
+
try {
|
|
125
|
+
if (!buf.startsWith("dump")) throw new Error("unknown request");
|
|
126
|
+
v8.takeCoverage();
|
|
127
|
+
reply = "ok\\n";
|
|
128
|
+
} catch (err) {
|
|
129
|
+
reply = "error " + (err && err.message ? err.message : String(err)) + "\\n";
|
|
130
|
+
}
|
|
131
|
+
conn.end(reply);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
server.unref();
|
|
135
|
+
server.on("error", (err) => {
|
|
136
|
+
if (err.code !== "EADDRINUSE") return;
|
|
137
|
+
// Someone holds the socket. If it answers, it is the live server and
|
|
138
|
+
// this process is a bystander; if not, it is a stale file.
|
|
139
|
+
const probe = net.connect(SOCK);
|
|
140
|
+
probe.on("connect", () => probe.destroy());
|
|
141
|
+
probe.on("error", () => {
|
|
142
|
+
try { fs.unlinkSync(SOCK); } catch {}
|
|
143
|
+
server.listen(SOCK);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
server.listen(SOCK);
|
|
147
|
+
}
|
|
148
|
+
`;
|
|
149
|
+
/**
|
|
150
|
+
* Coverage for a Node service. Sets `NODE_V8_COVERAGE` to the coverage
|
|
151
|
+
* directory (every node process in the container then writes V8
|
|
152
|
+
* coverage JSON when it exits) and `--require`s a hook that lets spectest
|
|
153
|
+
* ask the long-lived server process for a dump at capture time. Nothing
|
|
154
|
+
* for the app to write. Source maps: with `--enable-source-maps` (or a
|
|
155
|
+
* `sourceMappingURL` next to the file) Node records the map in the
|
|
156
|
+
* report, which is what maps a TypeScript service back to its sources.
|
|
157
|
+
*/
|
|
158
|
+
export function node() {
|
|
159
|
+
return {
|
|
160
|
+
name: "node",
|
|
161
|
+
configure(svc) {
|
|
162
|
+
const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
163
|
+
env.NODE_V8_COVERAGE = COVERAGE_CONTAINER_DIR;
|
|
164
|
+
return {
|
|
165
|
+
...svc,
|
|
166
|
+
env,
|
|
167
|
+
files: [...(svc.files ?? []), { path: NODE_COVERAGE_HOOK_PATH, content: NODE_COVERAGE_HOOK }],
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
async capture(ctx) {
|
|
171
|
+
const { connect } = await import("node:net");
|
|
172
|
+
const path = await import("node:path");
|
|
173
|
+
const sock = path.join(ctx.reportDir, NODE_COVERAGE_SOCKET);
|
|
174
|
+
const reply = await new Promise((resolve, reject) => {
|
|
175
|
+
const chunks = [];
|
|
176
|
+
const c = connect(sock, () => c.write("dump\n"));
|
|
177
|
+
const onAbort = () => {
|
|
178
|
+
c.destroy();
|
|
179
|
+
reject(new Error("timed out waiting for the node hook to write a report"));
|
|
180
|
+
};
|
|
181
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
182
|
+
c.on("data", (b) => chunks.push(b));
|
|
183
|
+
c.on("error", (err) => {
|
|
184
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
185
|
+
reject(new Error(err.code === "ENOENT" || err.code === "ECONNREFUSED"
|
|
186
|
+
? `no node process is serving ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_SOCKET} — is the service's main process node, and does it run with the service's env (NODE_OPTIONS)?`
|
|
187
|
+
: err.message));
|
|
188
|
+
});
|
|
189
|
+
c.on("close", () => {
|
|
190
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
191
|
+
resolve(Buffer.concat(chunks).toString("utf8").trim());
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
if (reply !== "ok")
|
|
195
|
+
throw new Error(`the node hook answered: ${reply || "(nothing)"}`);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// ── browser ───────────────────────────────────────────────────────
|
|
200
|
+
/**
|
|
201
|
+
* Coverage for the frontend a service serves. The code runs in the guest
|
|
202
|
+
* browser — spectest's own process — so no report can be written by the
|
|
203
|
+
* container; spectest collects V8 coverage for every script the browser
|
|
204
|
+
* loads from this service's origins, maps it back to source through the
|
|
205
|
+
* served source map, and writes one lcov per capture. Nothing ran yet on
|
|
206
|
+
* this branch ⇒ an lcov with no records, which is a report saying so.
|
|
207
|
+
*/
|
|
208
|
+
export function browser() {
|
|
209
|
+
return {
|
|
210
|
+
name: "browser",
|
|
211
|
+
load(ctx) {
|
|
212
|
+
ctx.collectBrowserScripts();
|
|
213
|
+
},
|
|
214
|
+
async capture(ctx) {
|
|
215
|
+
const lcov = browserCoverageReports().get(ctx.service) ?? "TN:\n";
|
|
216
|
+
await ctx.writeReport("browser.lcov", lcov);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// ── command ───────────────────────────────────────────────────────
|
|
221
|
+
/**
|
|
222
|
+
* Run `command` (via `sh -c`) in the container at every capture, so the
|
|
223
|
+
* service writes a fresh report into `/spectest/coverage/`. The escape
|
|
224
|
+
* hatch for a runtime with no shipped adapter.
|
|
225
|
+
*/
|
|
226
|
+
export function command(command) {
|
|
227
|
+
if (typeof command !== "string" || command.trim().length === 0) {
|
|
228
|
+
throw new Error("coverage.command: `command` must be a non-empty shell command");
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
name: "command",
|
|
232
|
+
async capture(ctx) {
|
|
233
|
+
await ctx.exec(command);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|