@specific.dev/spectest 0.59.3 → 0.60.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 +12 -1
- package/dist/browser-coverage.js +44 -9
- package/dist/coverage.d.ts +97 -16
- package/dist/coverage.js +193 -26
- package/dist/daemon.js +39 -12
- package/dist/harness/browser-coverage.d.ts +8 -0
- package/dist/harness/browser-coverage.js +18 -1
- package/dist/harness/coverage.d.ts +35 -0
- package/dist/harness/coverage.js +157 -0
- package/package.json +1 -1
- package/src/browser-coverage.ts +44 -10
- package/src/coverage.test.ts +183 -0
- package/src/coverage.ts +218 -31
- package/src/daemon.ts +49 -13
- package/src/harness/browser-coverage.test.ts +17 -0
- package/src/harness/browser-coverage.ts +19 -1
- package/src/harness/coverage.test.ts +72 -0
- package/src/harness/coverage.ts +167 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CDPSession } from "playwright-core";
|
|
2
|
+
import { type FileCounts } from "./harness/browser-coverage.js";
|
|
2
3
|
export interface BrowserCoverageConfig {
|
|
3
4
|
/** Which covered service (if any) serves scripts from this host. */
|
|
4
5
|
serviceForHost(host: string): string | undefined;
|
|
@@ -16,5 +17,15 @@ export declare function attachBrowserCoverage(cdp: CDPSession): Promise<void>;
|
|
|
16
17
|
* take, nothing is lost.
|
|
17
18
|
*/
|
|
18
19
|
export declare function harvestBrowserCoverage(cdp: CDPSession): Promise<void>;
|
|
19
|
-
/**
|
|
20
|
+
/** Fold one script's per-file counts into a service's totals. */
|
|
21
|
+
export declare function foldBrowserCoverage(service: string, files: FileCounts): void;
|
|
22
|
+
/** Harvest every live session, so a capture sees the counters of code
|
|
23
|
+
* that ran since the last browser op. */
|
|
24
|
+
export declare function harvestAllBrowserCoverage(): Promise<void>;
|
|
25
|
+
/** One lcov document per service that has any browser coverage — and
|
|
26
|
+
* the totals are cleared, so the next capture holds only what runs
|
|
27
|
+
* from now on. Module memory forks with the environment, so a forked
|
|
28
|
+
* child starts from its parent's last capture, empty. */
|
|
29
|
+
export declare function takeBrowserCoverageReports(): Map<string, string>;
|
|
30
|
+
/** The current totals, without clearing them (tests). */
|
|
20
31
|
export declare function browserCoverageReports(): Map<string, string>;
|
package/dist/browser-coverage.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// project with no `coverage: { browser: true }` service never attaches
|
|
20
20
|
// anything, so it costs nothing.
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
|
-
import { decodeSourceMap, lcovDocument, mapToOriginal, mergeFileCounts, rangesToLineCounts, } from "./harness/browser-coverage.js";
|
|
22
|
+
import { decodeSourceMap, isDocumentScriptPath, lcovDocument, mapToOriginal, mergeFileCounts, rangesToLineCounts, } from "./harness/browser-coverage.js";
|
|
23
23
|
import { rawFetch } from "./harness/raw-fetch.js";
|
|
24
24
|
let CONFIG = null;
|
|
25
25
|
/** Set (or clear, with `null`) at /load. */
|
|
@@ -32,6 +32,9 @@ export function browserCoverageActive() {
|
|
|
32
32
|
/** service → file → line → count, summed over every harvest. */
|
|
33
33
|
const TOTALS = new Map();
|
|
34
34
|
const COLLECTORS = new WeakMap();
|
|
35
|
+
/** The collectors of every session attached so far, for the harvest at
|
|
36
|
+
* capture; a closed session's take fails and is dropped there. */
|
|
37
|
+
const LIVE_COLLECTORS = new Set();
|
|
35
38
|
/** Decoded maps by URL (or content hash for inline `data:` maps). */
|
|
36
39
|
const SOURCE_MAPS = new Map();
|
|
37
40
|
const SOURCE_MAP_FETCH_TIMEOUT_MS = 10_000;
|
|
@@ -42,6 +45,7 @@ export async function attachBrowserCoverage(cdp) {
|
|
|
42
45
|
return;
|
|
43
46
|
const collector = { cdp, scripts: new Map(), inFlight: null };
|
|
44
47
|
COLLECTORS.set(cdp, collector);
|
|
48
|
+
LIVE_COLLECTORS.add(collector);
|
|
45
49
|
cdp.on("Debugger.scriptParsed", (ev) => {
|
|
46
50
|
if (!ev.url)
|
|
47
51
|
return;
|
|
@@ -72,7 +76,14 @@ export async function harvestBrowserCoverage(cdp) {
|
|
|
72
76
|
return c.inFlight;
|
|
73
77
|
}
|
|
74
78
|
async function harvestInner(c, config) {
|
|
75
|
-
|
|
79
|
+
let result;
|
|
80
|
+
try {
|
|
81
|
+
({ result } = (await c.cdp.send("Profiler.takePreciseCoverage")));
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
LIVE_COLLECTORS.delete(c); // the session is gone
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
76
87
|
for (const script of result) {
|
|
77
88
|
const info = c.scripts.get(script.scriptId) ?? (script.url ? { url: script.url } : undefined);
|
|
78
89
|
if (!info)
|
|
@@ -106,17 +117,23 @@ async function harvestInner(c, config) {
|
|
|
106
117
|
continue;
|
|
107
118
|
if (info.map === undefined)
|
|
108
119
|
info.map = await sourceMapFor(info, url);
|
|
120
|
+
if (!info.map && isDocumentScriptPath(url.pathname))
|
|
121
|
+
continue; // inline script in a page
|
|
109
122
|
const files = info.map
|
|
110
123
|
? mapToOriginal(info.map, generated)
|
|
111
124
|
: new Map([[url.pathname, generated]]);
|
|
112
|
-
|
|
113
|
-
if (!totals) {
|
|
114
|
-
totals = new Map();
|
|
115
|
-
TOTALS.set(service, totals);
|
|
116
|
-
}
|
|
117
|
-
mergeFileCounts(totals, files);
|
|
125
|
+
foldBrowserCoverage(service, files);
|
|
118
126
|
}
|
|
119
127
|
}
|
|
128
|
+
/** Fold one script's per-file counts into a service's totals. */
|
|
129
|
+
export function foldBrowserCoverage(service, files) {
|
|
130
|
+
let totals = TOTALS.get(service);
|
|
131
|
+
if (!totals) {
|
|
132
|
+
totals = new Map();
|
|
133
|
+
TOTALS.set(service, totals);
|
|
134
|
+
}
|
|
135
|
+
mergeFileCounts(totals, files);
|
|
136
|
+
}
|
|
120
137
|
/** Resolve, fetch (or inline-decode) and decode a script's source map. */
|
|
121
138
|
async function sourceMapFor(info, scriptUrl) {
|
|
122
139
|
let ref = info.sourceMapURL;
|
|
@@ -174,7 +191,25 @@ function parseDataUrl(ref) {
|
|
|
174
191
|
return null;
|
|
175
192
|
}
|
|
176
193
|
}
|
|
177
|
-
/**
|
|
194
|
+
/** Harvest every live session, so a capture sees the counters of code
|
|
195
|
+
* that ran since the last browser op. */
|
|
196
|
+
export async function harvestAllBrowserCoverage() {
|
|
197
|
+
await Promise.all([...LIVE_COLLECTORS].map((c) => harvestBrowserCoverage(c.cdp)));
|
|
198
|
+
}
|
|
199
|
+
/** One lcov document per service that has any browser coverage — and
|
|
200
|
+
* the totals are cleared, so the next capture holds only what runs
|
|
201
|
+
* from now on. Module memory forks with the environment, so a forked
|
|
202
|
+
* child starts from its parent's last capture, empty. */
|
|
203
|
+
export function takeBrowserCoverageReports() {
|
|
204
|
+
const out = new Map();
|
|
205
|
+
for (const [service, files] of TOTALS) {
|
|
206
|
+
if (files.size > 0)
|
|
207
|
+
out.set(service, lcovDocument(files));
|
|
208
|
+
}
|
|
209
|
+
TOTALS.clear();
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
/** The current totals, without clearing them (tests). */
|
|
178
213
|
export function browserCoverageReports() {
|
|
179
214
|
const out = new Map();
|
|
180
215
|
for (const [service, files] of TOTALS) {
|
package/dist/coverage.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ServiceConfig } from "./index.js";
|
|
2
|
-
import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
3
|
-
export { COVERAGE_CONTAINER_DIR };
|
|
2
|
+
import { COVERAGE_CONTAINER_DIR, type CoverageReportsMode } from "./harness/coverage.js";
|
|
3
|
+
export { COVERAGE_CONTAINER_DIR, type CoverageReportsMode };
|
|
4
4
|
/** Where `node()` mounts its hook inside the container. */
|
|
5
5
|
export declare const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
6
6
|
/** The report `node()` writes when nothing has run yet on a branch. */
|
|
@@ -8,6 +8,22 @@ export declare const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json"
|
|
|
8
8
|
/** Prefix of the per-process sockets the node hook answers on, relative
|
|
9
9
|
* to the coverage dir: `.ctl-<pid>`. */
|
|
10
10
|
export declare const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
11
|
+
/** Where golden keeps the conversion tools (`c8` and its dependencies),
|
|
12
|
+
* seen from the VM. `SPECTEST_COVERAGE_TOOLS_DIR` overrides it (tests).
|
|
13
|
+
* The daemon bind-mounts it read-only into every covered container at
|
|
14
|
+
* {@link NODE_COVERAGE_TOOLS_CONTAINER_DIR} when it exists. */
|
|
15
|
+
export declare const NODE_COVERAGE_TOOLS_DIR = "/opt/spectest/coverage-tools";
|
|
16
|
+
/** Where the container sees {@link NODE_COVERAGE_TOOLS_DIR}. */
|
|
17
|
+
export declare const NODE_COVERAGE_TOOLS_CONTAINER_DIR = "/spectest/coverage-tools";
|
|
18
|
+
/** The `c8` entry point, relative to the tools dir. */
|
|
19
|
+
export declare const NODE_COVERAGE_C8_BIN = "node_modules/c8/bin/c8.js";
|
|
20
|
+
/** The hidden subdirectory of the coverage dir where `node()` parks the
|
|
21
|
+
* V8 dumps it converts at one capture. Hidden, so the harness never
|
|
22
|
+
* ships them: the lcov is the report. Emptied at every capture — the
|
|
23
|
+
* lcov is this capture's delta, see {@link convertV8ReportsToLcov}. */
|
|
24
|
+
export declare const NODE_COVERAGE_DUMPS_SUBDIR = ".v8";
|
|
25
|
+
/** The lcov `node()` writes: the dumps of this capture, merged. */
|
|
26
|
+
export declare const NODE_COVERAGE_LCOV = "node.lcov";
|
|
11
27
|
/** What `configure` learns about the service it rewrites. */
|
|
12
28
|
export interface CoverageConfigureInfo {
|
|
13
29
|
/** The services-map key. */
|
|
@@ -54,6 +70,15 @@ export interface CoverageCaptureContext {
|
|
|
54
70
|
export interface CoverageAdapter {
|
|
55
71
|
/** Names the adapter in errors (`coverage adapter "node" on service …`). */
|
|
56
72
|
name: string;
|
|
73
|
+
/**
|
|
74
|
+
* How the reports this adapter writes (through `ctx.writeReport`)
|
|
75
|
+
* count: `"delta"` — what ran since the previous capture, the tool
|
|
76
|
+
* resets its own counters; `"cumulative"` (the default) — since
|
|
77
|
+
* process start, and spectest subtracts the previous capture. Reports
|
|
78
|
+
* the adapter makes appear some other way (`command`) follow the
|
|
79
|
+
* service's own `reports`.
|
|
80
|
+
*/
|
|
81
|
+
reports?: CoverageReportsMode;
|
|
57
82
|
/**
|
|
58
83
|
* Rewrite the service at config time. Must be pure and deterministic
|
|
59
84
|
* (the result is hashed into the warm-template key) and must **append**
|
|
@@ -65,11 +90,16 @@ export interface CoverageAdapter {
|
|
|
65
90
|
/** Make the report appear in the directory. A throw fails the test. */
|
|
66
91
|
capture?(ctx: CoverageCaptureContext): void | Promise<void>;
|
|
67
92
|
}
|
|
68
|
-
/** `ServiceConfig.coverage`: the program writes its own reports,
|
|
69
|
-
* list of adapters gets them out
|
|
93
|
+
/** `ServiceConfig.coverage`: the program writes its own reports (`true`),
|
|
94
|
+
* or a list of adapters gets them out, and/or how the reports the
|
|
95
|
+
* program itself writes count (`reports`, default `"cumulative"`). */
|
|
70
96
|
export type ServiceCoverage = true | {
|
|
71
|
-
adapters
|
|
97
|
+
adapters?: readonly CoverageAdapter[];
|
|
98
|
+
reports?: CoverageReportsMode;
|
|
72
99
|
};
|
|
100
|
+
/** The `reports` mode of a `coverage` value, for the reports no adapter
|
|
101
|
+
* wrote (the program's own, a `command`'s). */
|
|
102
|
+
export declare function coverageReportsMode(cov: ServiceCoverage | undefined): CoverageReportsMode;
|
|
73
103
|
/** Type an inline adapter. Identity at runtime. */
|
|
74
104
|
export declare function defineAdapter(adapter: CoverageAdapter): CoverageAdapter;
|
|
75
105
|
/** Check one service's `coverage` field. Exported so the daemon applies
|
|
@@ -99,6 +129,10 @@ export declare function appendEnvFlag(env: Readonly<Record<string, string>> | un
|
|
|
99
129
|
* wrapper dumped the wrapper and silently never the server (reported by
|
|
100
130
|
* a user 2026-08-27). At capture spectest asks every live socket and
|
|
101
131
|
* unlinks the stale ones. Unref'd, so a short-lived process still exits.
|
|
132
|
+
* Main thread only: a worker thread inherits `NODE_OPTIONS`, and tsx's
|
|
133
|
+
* ESM loader thread bound the shared per-pid path last, so every
|
|
134
|
+
* `--import tsx` server answered with the loader's coverage — the app's
|
|
135
|
+
* main thread was never dumped (found in the same user's first full run).
|
|
102
136
|
* No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
|
|
103
137
|
* Temporal worker, restarts nodemon), a socket is nobody's.
|
|
104
138
|
*/
|
|
@@ -110,13 +144,52 @@ export declare const NODE_COVERAGE_HOOK: string;
|
|
|
110
144
|
* container then writes V8 coverage JSON when it exits) and `--require`s
|
|
111
145
|
* a hook that lets spectest ask every live node process for a dump at
|
|
112
146
|
* capture time — the server, and any wrapper it sits behind (`pnpm exec`,
|
|
113
|
-
* `tsx`). Nothing
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* `
|
|
117
|
-
*
|
|
147
|
+
* `tsx`). Nothing for the app to write.
|
|
148
|
+
*
|
|
149
|
+
* At capture the dumps are **converted to lcov inside the container**
|
|
150
|
+
* with `c8` (`c8 report`), and `node.lcov` is the one report that
|
|
151
|
+
* ships. It holds **what ran since the previous capture**: V8 resets its
|
|
152
|
+
* counters at every `takeCoverage()`, so a live server's dump after a
|
|
153
|
+
* test is that test's own execution — the boot dump (everything loaded,
|
|
154
|
+
* which at file level is everything "ran") lands in the bring-up
|
|
155
|
+
* capture and nowhere else. A run's total is the union over its cases
|
|
156
|
+
* on the server; a test's set is the test's own. Merging every dump on
|
|
157
|
+
* the branch instead was tried first and would have made a long-lived
|
|
158
|
+
* server's per-test set the boot set, every time. The conversion runs
|
|
159
|
+
* in the container, not in the harness, because `v8-to-istanbul` needs
|
|
160
|
+
* the script text (for byte offsets → lines) and the source maps
|
|
161
|
+
* (`--enable-source-maps`, or a `sourceMappingURL` next to each file),
|
|
162
|
+
* and only the container has them. `c8` comes from golden
|
|
163
|
+
* ({@link NODE_COVERAGE_TOOLS_DIR}); on a guest without it the raw V8
|
|
164
|
+
* documents ship instead, compacted ({@link compactV8Document}), as they
|
|
165
|
+
* did before SDK 0.60.
|
|
118
166
|
*/
|
|
119
167
|
export declare function node(): CoverageAdapter;
|
|
168
|
+
/** The VM-side tools dir, after the test override. */
|
|
169
|
+
export declare function nodeCoverageToolsDir(): string;
|
|
170
|
+
/** True when golden (or the override) ships `c8`. The daemon mounts the
|
|
171
|
+
* directory into covered containers on the same check, so what the VM
|
|
172
|
+
* has is what the container sees. */
|
|
173
|
+
export declare function nodeCoverageToolsAvailable(): Promise<boolean>;
|
|
174
|
+
/** The command `convertV8ReportsToLcov` runs in the container. From `/`,
|
|
175
|
+
* so lcov paths come out relative to the root (made absolute after). */
|
|
176
|
+
export declare function nodeCoverageConvertCommand(): string;
|
|
177
|
+
/**
|
|
178
|
+
* Convert the V8 dumps written since the previous capture to one lcov,
|
|
179
|
+
* `node.lcov`. New dumps at the root of the dir are compacted to the
|
|
180
|
+
* app's scripts (their maps and `sourcesContent` kept — `v8-to-istanbul`
|
|
181
|
+
* reads the original sources from there when they are not on disk, the
|
|
182
|
+
* usual shape of a multi-stage image) and moved into `.v8/`, which is
|
|
183
|
+
* emptied first; `c8 report` merges that directory. So the lcov is this
|
|
184
|
+
* capture's delta: what the live processes ran since their last take,
|
|
185
|
+
* plus every process that exited since. No new dump ⇒ `TN:` alone,
|
|
186
|
+
* nothing ran. The `SF:` paths come out relative to `/` and are made
|
|
187
|
+
* absolute, so they read as container paths like every other tool's.
|
|
188
|
+
*/
|
|
189
|
+
export declare function convertV8ReportsToLcov(ctx: CoverageCaptureContext): Promise<void>;
|
|
190
|
+
/** `SF:` records relative to `/` (what `c8` run from `/` writes) made
|
|
191
|
+
* absolute. An lcov with nothing in it becomes the empty report. */
|
|
192
|
+
export declare function absoluteLcovPaths(lcov: string): string;
|
|
120
193
|
/**
|
|
121
194
|
* Ask every node process that holds a hook socket in `dir` for a dump.
|
|
122
195
|
* A socket nobody answers (its process died without unlinking — SIGKILL,
|
|
@@ -136,10 +209,13 @@ export declare function isAppScriptUrl(url: string): boolean;
|
|
|
136
209
|
* per capture, of which the app's own coverage was under 0.5 MiB, and a
|
|
137
210
|
* suite that hit the 64 MiB cap on its third test. Kept: `file://`
|
|
138
211
|
* scripts outside `node_modules`, the map entries of exactly those
|
|
139
|
-
* scripts, minus `sourcesContent` (the sources are the repo)
|
|
140
|
-
*
|
|
212
|
+
* scripts, minus `sourcesContent` (the sources are the repo) unless
|
|
213
|
+
* `keepSourcesContent` — the lcov conversion reads original sources from
|
|
214
|
+
* there. Still a V8 document — nothing is converted here.
|
|
141
215
|
*/
|
|
142
|
-
export declare function compactV8Document(doc: Record<string, unknown
|
|
216
|
+
export declare function compactV8Document(doc: Record<string, unknown>, opts?: {
|
|
217
|
+
keepSourcesContent?: boolean;
|
|
218
|
+
}): Record<string, unknown>;
|
|
143
219
|
/** Compact every not-yet-compacted V8 document in `dir`, in place. */
|
|
144
220
|
export declare function compactV8Reports(dir: string): Promise<void>;
|
|
145
221
|
/**
|
|
@@ -147,13 +223,18 @@ export declare function compactV8Reports(dir: string): Promise<void>;
|
|
|
147
223
|
* browser — spectest's own process — so no report can be written by the
|
|
148
224
|
* container; spectest collects V8 coverage for every script the browser
|
|
149
225
|
* loads from this service's origins, maps it back to source through the
|
|
150
|
-
* served source map, and writes one lcov per capture.
|
|
151
|
-
*
|
|
226
|
+
* served source map, and writes one lcov per capture (`browser.lcov`)
|
|
227
|
+
* holding what ran since the previous capture: every live session is
|
|
228
|
+
* harvested first, and the totals are taken and cleared. Nothing ran ⇒
|
|
229
|
+
* an lcov with no records, which is a report saying so.
|
|
152
230
|
*/
|
|
153
231
|
export declare function browser(): CoverageAdapter;
|
|
154
232
|
/**
|
|
155
233
|
* Run `command` (via `sh -c`) in the container at every capture, so the
|
|
156
234
|
* service writes a fresh report into `/spectest/coverage/`. The escape
|
|
157
|
-
* hatch for a runtime with no shipped adapter.
|
|
235
|
+
* hatch for a runtime with no shipped adapter. The reports it makes
|
|
236
|
+
* appear follow the service's `reports` mode (cumulative by default —
|
|
237
|
+
* spectest subtracts the previous capture; `{ reports: "delta" }` when
|
|
238
|
+
* the tool resets its own counters).
|
|
158
239
|
*/
|
|
159
240
|
export declare function command(command: string): CoverageAdapter;
|
package/dist/coverage.js
CHANGED
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
//
|
|
15
15
|
// After every adapter ran, the harness reads the directory the same way
|
|
16
16
|
// it does for `coverage: true`: at least one well-formed report (lcov or
|
|
17
|
-
// V8 JSON), shipped verbatim.
|
|
18
|
-
//
|
|
17
|
+
// V8 JSON), shipped verbatim. The control plane derives what it needs
|
|
18
|
+
// from the stored bytes. One adapter converts: `node()` turns the V8
|
|
19
|
+
// dumps into lcov inside the container (with `c8`, which golden ships),
|
|
20
|
+
// because the conversion needs the sources and the source maps, which
|
|
21
|
+
// only the container has.
|
|
19
22
|
//
|
|
20
23
|
// Imported from `@specific.dev/spectest/coverage`. The three shipped
|
|
21
24
|
// adapters: `node()` (V8 JSON through a hook spectest mounts), `browser()`
|
|
@@ -24,7 +27,7 @@
|
|
|
24
27
|
//
|
|
25
28
|
// import * as coverage from "@specific.dev/spectest/coverage";
|
|
26
29
|
// coverage: { adapters: [coverage.node(), coverage.browser()] }
|
|
27
|
-
import {
|
|
30
|
+
import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
|
|
28
31
|
import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
29
32
|
export { COVERAGE_CONTAINER_DIR };
|
|
30
33
|
/** Where `node()` mounts its hook inside the container. */
|
|
@@ -34,6 +37,27 @@ export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
|
|
|
34
37
|
/** Prefix of the per-process sockets the node hook answers on, relative
|
|
35
38
|
* to the coverage dir: `.ctl-<pid>`. */
|
|
36
39
|
export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
40
|
+
/** Where golden keeps the conversion tools (`c8` and its dependencies),
|
|
41
|
+
* seen from the VM. `SPECTEST_COVERAGE_TOOLS_DIR` overrides it (tests).
|
|
42
|
+
* The daemon bind-mounts it read-only into every covered container at
|
|
43
|
+
* {@link NODE_COVERAGE_TOOLS_CONTAINER_DIR} when it exists. */
|
|
44
|
+
export const NODE_COVERAGE_TOOLS_DIR = "/opt/spectest/coverage-tools";
|
|
45
|
+
/** Where the container sees {@link NODE_COVERAGE_TOOLS_DIR}. */
|
|
46
|
+
export const NODE_COVERAGE_TOOLS_CONTAINER_DIR = "/spectest/coverage-tools";
|
|
47
|
+
/** The `c8` entry point, relative to the tools dir. */
|
|
48
|
+
export const NODE_COVERAGE_C8_BIN = "node_modules/c8/bin/c8.js";
|
|
49
|
+
/** The hidden subdirectory of the coverage dir where `node()` parks the
|
|
50
|
+
* V8 dumps it converts at one capture. Hidden, so the harness never
|
|
51
|
+
* ships them: the lcov is the report. Emptied at every capture — the
|
|
52
|
+
* lcov is this capture's delta, see {@link convertV8ReportsToLcov}. */
|
|
53
|
+
export const NODE_COVERAGE_DUMPS_SUBDIR = ".v8";
|
|
54
|
+
/** The lcov `node()` writes: the dumps of this capture, merged. */
|
|
55
|
+
export const NODE_COVERAGE_LCOV = "node.lcov";
|
|
56
|
+
/** The `reports` mode of a `coverage` value, for the reports no adapter
|
|
57
|
+
* wrote (the program's own, a `command`'s). */
|
|
58
|
+
export function coverageReportsMode(cov) {
|
|
59
|
+
return typeof cov === "object" && cov !== null && cov.reports ? cov.reports : "cumulative";
|
|
60
|
+
}
|
|
37
61
|
/** Type an inline adapter. Identity at runtime. */
|
|
38
62
|
export function defineAdapter(adapter) {
|
|
39
63
|
return adapter;
|
|
@@ -43,26 +67,34 @@ export function defineAdapter(adapter) {
|
|
|
43
67
|
export function validateCoverage(service, cov) {
|
|
44
68
|
if (cov === undefined || cov === true)
|
|
45
69
|
return;
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
const obj = typeof cov === "object" && cov !== null ? cov : null;
|
|
71
|
+
const adapters = obj?.adapters;
|
|
72
|
+
const reports = obj?.reports;
|
|
73
|
+
const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports");
|
|
74
|
+
const okReports = reports === undefined || reports === "delta" || reports === "cumulative";
|
|
75
|
+
const okAdapters = adapters === undefined || Array.isArray(adapters);
|
|
76
|
+
if (obj !== null && knownKeys && okReports && okAdapters && (adapters !== undefined || reports !== undefined)) {
|
|
77
|
+
for (const a of adapters ?? []) {
|
|
49
78
|
const ok = typeof a === "object" &&
|
|
50
79
|
a !== null &&
|
|
51
80
|
typeof a.name === "string" &&
|
|
52
81
|
a.name.length > 0 &&
|
|
82
|
+
(a.reports === undefined ||
|
|
83
|
+
a.reports === "delta" ||
|
|
84
|
+
a.reports === "cumulative") &&
|
|
53
85
|
["configure", "load", "capture"].every((k) => a[k] === undefined ||
|
|
54
86
|
typeof a[k] === "function");
|
|
55
87
|
if (!ok) {
|
|
56
|
-
throw new Error(`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, configure?, load?, capture? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`);
|
|
88
|
+
throw new Error(`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, configure?, load?, capture? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`);
|
|
57
89
|
}
|
|
58
90
|
}
|
|
59
91
|
return;
|
|
60
92
|
}
|
|
61
|
-
throw new Error(`service "${service}" has an invalid \`coverage\` value — use \`true\` (the program writes its own reports)
|
|
93
|
+
throw new Error(`service "${service}" has an invalid \`coverage\` value — use \`true\` (the program writes its own reports), \`{ adapters: [...] }\` from @specific.dev/spectest/coverage (\`node()\`, \`browser()\`, \`command("…")\`), and/or \`{ reports: "delta" | "cumulative" }\``);
|
|
62
94
|
}
|
|
63
95
|
/** The adapters of a `coverage` value (`true` has none). */
|
|
64
96
|
export function coverageAdapters(cov) {
|
|
65
|
-
return typeof cov === "object" && cov !== null ? cov.adapters : [];
|
|
97
|
+
return typeof cov === "object" && cov !== null ? (cov.adapters ?? []) : [];
|
|
66
98
|
}
|
|
67
99
|
/**
|
|
68
100
|
* Apply every adapter's `configure` to a service, in list order. Called by
|
|
@@ -81,7 +113,8 @@ export function applyCoverageAdapters(key, svc) {
|
|
|
81
113
|
if (a.configure)
|
|
82
114
|
out = a.configure(out, { key });
|
|
83
115
|
}
|
|
84
|
-
const
|
|
116
|
+
const mode = svc.coverage.reports;
|
|
117
|
+
const wire = { adapters: adapters.map((a) => withWireName(a)), ...(mode ? { reports: mode } : {}) };
|
|
85
118
|
return { ...out, coverage: wire };
|
|
86
119
|
}
|
|
87
120
|
function withWireName(a) {
|
|
@@ -109,12 +142,20 @@ export function appendEnvFlag(env, name, value) {
|
|
|
109
142
|
* wrapper dumped the wrapper and silently never the server (reported by
|
|
110
143
|
* a user 2026-08-27). At capture spectest asks every live socket and
|
|
111
144
|
* unlinks the stale ones. Unref'd, so a short-lived process still exits.
|
|
145
|
+
* Main thread only: a worker thread inherits `NODE_OPTIONS`, and tsx's
|
|
146
|
+
* ESM loader thread bound the shared per-pid path last, so every
|
|
147
|
+
* `--import tsx` server answered with the loader's coverage — the app's
|
|
148
|
+
* main thread was never dumped (found in the same user's first full run).
|
|
112
149
|
* No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
|
|
113
150
|
* Temporal worker, restarts nodemon), a socket is nobody's.
|
|
114
151
|
*/
|
|
115
152
|
export const NODE_COVERAGE_HOOK = `"use strict";
|
|
116
153
|
// spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
|
|
117
|
-
|
|
154
|
+
// Main thread only: worker threads inherit NODE_OPTIONS and would bind
|
|
155
|
+
// the same per-pid socket last — tsx's ESM loader runs on one, and it
|
|
156
|
+
// stole the socket from every \`--import tsx\` server, so a dump was the
|
|
157
|
+
// loader's isolate and never the app's.
|
|
158
|
+
if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread) {
|
|
118
159
|
const net = require("node:net");
|
|
119
160
|
const fs = require("node:fs");
|
|
120
161
|
const v8 = require("node:v8");
|
|
@@ -150,15 +191,30 @@ if (process.env.NODE_V8_COVERAGE) {
|
|
|
150
191
|
* container then writes V8 coverage JSON when it exits) and `--require`s
|
|
151
192
|
* a hook that lets spectest ask every live node process for a dump at
|
|
152
193
|
* capture time — the server, and any wrapper it sits behind (`pnpm exec`,
|
|
153
|
-
* `tsx`). Nothing
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* `
|
|
157
|
-
*
|
|
194
|
+
* `tsx`). Nothing for the app to write.
|
|
195
|
+
*
|
|
196
|
+
* At capture the dumps are **converted to lcov inside the container**
|
|
197
|
+
* with `c8` (`c8 report`), and `node.lcov` is the one report that
|
|
198
|
+
* ships. It holds **what ran since the previous capture**: V8 resets its
|
|
199
|
+
* counters at every `takeCoverage()`, so a live server's dump after a
|
|
200
|
+
* test is that test's own execution — the boot dump (everything loaded,
|
|
201
|
+
* which at file level is everything "ran") lands in the bring-up
|
|
202
|
+
* capture and nowhere else. A run's total is the union over its cases
|
|
203
|
+
* on the server; a test's set is the test's own. Merging every dump on
|
|
204
|
+
* the branch instead was tried first and would have made a long-lived
|
|
205
|
+
* server's per-test set the boot set, every time. The conversion runs
|
|
206
|
+
* in the container, not in the harness, because `v8-to-istanbul` needs
|
|
207
|
+
* the script text (for byte offsets → lines) and the source maps
|
|
208
|
+
* (`--enable-source-maps`, or a `sourceMappingURL` next to each file),
|
|
209
|
+
* and only the container has them. `c8` comes from golden
|
|
210
|
+
* ({@link NODE_COVERAGE_TOOLS_DIR}); on a guest without it the raw V8
|
|
211
|
+
* documents ship instead, compacted ({@link compactV8Document}), as they
|
|
212
|
+
* did before SDK 0.60.
|
|
158
213
|
*/
|
|
159
214
|
export function node() {
|
|
160
215
|
return {
|
|
161
216
|
name: "node",
|
|
217
|
+
reports: "delta",
|
|
162
218
|
configure(svc) {
|
|
163
219
|
const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
164
220
|
env.NODE_V8_COVERAGE = COVERAGE_CONTAINER_DIR;
|
|
@@ -173,12 +229,16 @@ export function node() {
|
|
|
173
229
|
// here; a service whose node processes are short-lived (a CLI run
|
|
174
230
|
// by `ctx.exec` from a `sleep infinity` container) has no socket to
|
|
175
231
|
// answer and its coverage is the exit-time dumps already on disk.
|
|
176
|
-
// So no live process is not an error. No
|
|
177
|
-
// has run yet on this branch — is recorded as an empty
|
|
232
|
+
// So no live process is not an error. No dump at all — nothing
|
|
233
|
+
// has run yet on this branch — is recorded as an empty report
|
|
178
234
|
// rather than failed: an empty report is a report. A server whose
|
|
179
235
|
// hook never loaded (NODE_OPTIONS not reaching it) then shows as
|
|
180
236
|
// empty reports after bring-up, which the boot log warns about.
|
|
181
237
|
await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
|
|
238
|
+
if (await nodeCoverageToolsAvailable()) {
|
|
239
|
+
await convertV8ReportsToLcov(ctx);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
182
242
|
await compactV8Reports(ctx.reportDir);
|
|
183
243
|
if (!(await hasV8Reports(ctx.reportDir))) {
|
|
184
244
|
await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
|
|
@@ -186,6 +246,102 @@ export function node() {
|
|
|
186
246
|
},
|
|
187
247
|
};
|
|
188
248
|
}
|
|
249
|
+
/** The VM-side tools dir, after the test override. */
|
|
250
|
+
export function nodeCoverageToolsDir() {
|
|
251
|
+
return process.env.SPECTEST_COVERAGE_TOOLS_DIR || NODE_COVERAGE_TOOLS_DIR;
|
|
252
|
+
}
|
|
253
|
+
/** True when golden (or the override) ships `c8`. The daemon mounts the
|
|
254
|
+
* directory into covered containers on the same check, so what the VM
|
|
255
|
+
* has is what the container sees. */
|
|
256
|
+
export async function nodeCoverageToolsAvailable() {
|
|
257
|
+
const fs = await import("node:fs/promises");
|
|
258
|
+
const path = await import("node:path");
|
|
259
|
+
try {
|
|
260
|
+
await fs.access(path.join(nodeCoverageToolsDir(), NODE_COVERAGE_C8_BIN));
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/** The command `convertV8ReportsToLcov` runs in the container. From `/`,
|
|
268
|
+
* so lcov paths come out relative to the root (made absolute after). */
|
|
269
|
+
export function nodeCoverageConvertCommand() {
|
|
270
|
+
const c8 = `${NODE_COVERAGE_TOOLS_CONTAINER_DIR}/${NODE_COVERAGE_C8_BIN}`;
|
|
271
|
+
const dumps = `${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_DUMPS_SUBDIR}`;
|
|
272
|
+
const out = `${COVERAGE_CONTAINER_DIR}/.c8`;
|
|
273
|
+
// `env -u`: the converter is a node process in a container whose env
|
|
274
|
+
// carries NODE_V8_COVERAGE and the `--require` hook, so without this it
|
|
275
|
+
// would load the hook and write a dump of itself at exit — a raw V8
|
|
276
|
+
// document at the root of the dir, shipped as a report (seen on the
|
|
277
|
+
// first run of hello-node under 0.60).
|
|
278
|
+
return `cd / && env -u NODE_V8_COVERAGE -u NODE_OPTIONS node ${c8} report --temp-directory ${dumps} --reporter=lcovonly --reports-dir ${out}`;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Convert the V8 dumps written since the previous capture to one lcov,
|
|
282
|
+
* `node.lcov`. New dumps at the root of the dir are compacted to the
|
|
283
|
+
* app's scripts (their maps and `sourcesContent` kept — `v8-to-istanbul`
|
|
284
|
+
* reads the original sources from there when they are not on disk, the
|
|
285
|
+
* usual shape of a multi-stage image) and moved into `.v8/`, which is
|
|
286
|
+
* emptied first; `c8 report` merges that directory. So the lcov is this
|
|
287
|
+
* capture's delta: what the live processes ran since their last take,
|
|
288
|
+
* plus every process that exited since. No new dump ⇒ `TN:` alone,
|
|
289
|
+
* nothing ran. The `SF:` paths come out relative to `/` and are made
|
|
290
|
+
* absolute, so they read as container paths like every other tool's.
|
|
291
|
+
*/
|
|
292
|
+
export async function convertV8ReportsToLcov(ctx) {
|
|
293
|
+
const fs = await import("node:fs/promises");
|
|
294
|
+
const path = await import("node:path");
|
|
295
|
+
const dumpsDir = path.join(ctx.reportDir, NODE_COVERAGE_DUMPS_SUBDIR);
|
|
296
|
+
await fs.rm(dumpsDir, { recursive: true, force: true });
|
|
297
|
+
await fs.mkdir(dumpsDir, { recursive: true });
|
|
298
|
+
await fs.chmod(dumpsDir, 0o755);
|
|
299
|
+
let dumps = 0;
|
|
300
|
+
for (const name of await fs.readdir(ctx.reportDir)) {
|
|
301
|
+
if (!name.startsWith("coverage-") || !name.endsWith(".json"))
|
|
302
|
+
continue;
|
|
303
|
+
const file = path.join(ctx.reportDir, name);
|
|
304
|
+
if (name === NODE_COVERAGE_EMPTY_REPORT) {
|
|
305
|
+
await fs.unlink(file).catch(() => { }); // a pre-0.60 placeholder
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
let doc;
|
|
309
|
+
try {
|
|
310
|
+
doc = JSON.parse(await fs.readFile(file, "utf8"));
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
continue; // a dump mid-write; the next capture takes it
|
|
314
|
+
}
|
|
315
|
+
if (!Array.isArray(doc.result))
|
|
316
|
+
continue;
|
|
317
|
+
const target = path.join(dumpsDir, name);
|
|
318
|
+
await fs.writeFile(`${target}.tmp`, JSON.stringify(compactV8Document(doc, { keepSourcesContent: true })));
|
|
319
|
+
await fs.chmod(`${target}.tmp`, 0o644);
|
|
320
|
+
await fs.rename(`${target}.tmp`, target);
|
|
321
|
+
await fs.unlink(file);
|
|
322
|
+
dumps++;
|
|
323
|
+
}
|
|
324
|
+
if (dumps === 0) {
|
|
325
|
+
await ctx.writeReport(NODE_COVERAGE_LCOV, "TN:\n");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
await ctx.exec(nodeCoverageConvertCommand());
|
|
329
|
+
let lcov;
|
|
330
|
+
try {
|
|
331
|
+
lcov = await fs.readFile(path.join(ctx.reportDir, ".c8", "lcov.info"), "utf8");
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
throw new Error(`c8 wrote no lcov.info: ${err instanceof Error ? err.message : String(err)}`);
|
|
335
|
+
}
|
|
336
|
+
await ctx.writeReport(NODE_COVERAGE_LCOV, absoluteLcovPaths(lcov));
|
|
337
|
+
}
|
|
338
|
+
/** `SF:` records relative to `/` (what `c8` run from `/` writes) made
|
|
339
|
+
* absolute. An lcov with nothing in it becomes the empty report. */
|
|
340
|
+
export function absoluteLcovPaths(lcov) {
|
|
341
|
+
if (lcov.trim().length === 0)
|
|
342
|
+
return "TN:\n";
|
|
343
|
+
return lcov.replace(/^SF:(?!\/)/gm, "SF:/");
|
|
344
|
+
}
|
|
189
345
|
/**
|
|
190
346
|
* Ask every node process that holds a hook socket in `dir` for a dump.
|
|
191
347
|
* A socket nobody answers (its process died without unlinking — SIGKILL,
|
|
@@ -259,6 +415,9 @@ const COMPACTED_V8_REPORTS = new Set();
|
|
|
259
415
|
export function isAppScriptUrl(url) {
|
|
260
416
|
return (url.startsWith("file://") &&
|
|
261
417
|
!url.includes("/node_modules/") &&
|
|
418
|
+
// Package managers run from a cache, not node_modules: corepack's pnpm
|
|
419
|
+
// is `/root/.cache/node/corepack/…`, 2 MiB of dump per `pnpm run`.
|
|
420
|
+
!url.includes("/.cache/") &&
|
|
262
421
|
!url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop()));
|
|
263
422
|
}
|
|
264
423
|
/**
|
|
@@ -270,10 +429,11 @@ export function isAppScriptUrl(url) {
|
|
|
270
429
|
* per capture, of which the app's own coverage was under 0.5 MiB, and a
|
|
271
430
|
* suite that hit the 64 MiB cap on its third test. Kept: `file://`
|
|
272
431
|
* scripts outside `node_modules`, the map entries of exactly those
|
|
273
|
-
* scripts, minus `sourcesContent` (the sources are the repo)
|
|
274
|
-
*
|
|
432
|
+
* scripts, minus `sourcesContent` (the sources are the repo) unless
|
|
433
|
+
* `keepSourcesContent` — the lcov conversion reads original sources from
|
|
434
|
+
* there. Still a V8 document — nothing is converted here.
|
|
275
435
|
*/
|
|
276
|
-
export function compactV8Document(doc) {
|
|
436
|
+
export function compactV8Document(doc, opts = {}) {
|
|
277
437
|
const result = Array.isArray(doc.result) ? doc.result : [];
|
|
278
438
|
const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url));
|
|
279
439
|
const out = { ...doc, result: kept };
|
|
@@ -285,7 +445,7 @@ export function compactV8Document(doc) {
|
|
|
285
445
|
if (!urls.has(url) || !entry || typeof entry !== "object")
|
|
286
446
|
continue;
|
|
287
447
|
const e = { ...entry };
|
|
288
|
-
if (e.data && typeof e.data === "object") {
|
|
448
|
+
if (!opts.keepSourcesContent && e.data && typeof e.data === "object") {
|
|
289
449
|
const { sourcesContent: _dropped, ...data } = e.data;
|
|
290
450
|
e.data = data;
|
|
291
451
|
}
|
|
@@ -336,17 +496,21 @@ export async function compactV8Reports(dir) {
|
|
|
336
496
|
* browser — spectest's own process — so no report can be written by the
|
|
337
497
|
* container; spectest collects V8 coverage for every script the browser
|
|
338
498
|
* loads from this service's origins, maps it back to source through the
|
|
339
|
-
* served source map, and writes one lcov per capture.
|
|
340
|
-
*
|
|
499
|
+
* served source map, and writes one lcov per capture (`browser.lcov`)
|
|
500
|
+
* holding what ran since the previous capture: every live session is
|
|
501
|
+
* harvested first, and the totals are taken and cleared. Nothing ran ⇒
|
|
502
|
+
* an lcov with no records, which is a report saying so.
|
|
341
503
|
*/
|
|
342
504
|
export function browser() {
|
|
343
505
|
return {
|
|
344
506
|
name: "browser",
|
|
507
|
+
reports: "delta",
|
|
345
508
|
load(ctx) {
|
|
346
509
|
ctx.collectBrowserScripts();
|
|
347
510
|
},
|
|
348
511
|
async capture(ctx) {
|
|
349
|
-
|
|
512
|
+
await harvestAllBrowserCoverage();
|
|
513
|
+
const lcov = takeBrowserCoverageReports().get(ctx.service) ?? "TN:\n";
|
|
350
514
|
await ctx.writeReport("browser.lcov", lcov);
|
|
351
515
|
},
|
|
352
516
|
};
|
|
@@ -355,7 +519,10 @@ export function browser() {
|
|
|
355
519
|
/**
|
|
356
520
|
* Run `command` (via `sh -c`) in the container at every capture, so the
|
|
357
521
|
* service writes a fresh report into `/spectest/coverage/`. The escape
|
|
358
|
-
* hatch for a runtime with no shipped adapter.
|
|
522
|
+
* hatch for a runtime with no shipped adapter. The reports it makes
|
|
523
|
+
* appear follow the service's `reports` mode (cumulative by default —
|
|
524
|
+
* spectest subtracts the previous capture; `{ reports: "delta" }` when
|
|
525
|
+
* the tool resets its own counters).
|
|
359
526
|
*/
|
|
360
527
|
export function command(command) {
|
|
361
528
|
if (typeof command !== "string" || command.trim().length === 0) {
|