@specific.dev/spectest 0.59.2 → 0.59.4
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.js +3 -1
- package/dist/coverage.d.ts +15 -6
- package/dist/coverage.js +50 -12
- package/dist/harness/browser-coverage.d.ts +8 -0
- package/dist/harness/browser-coverage.js +18 -1
- package/package.json +1 -1
- package/src/browser-coverage.ts +2 -0
- package/src/coverage.test.ts +61 -5
- package/src/coverage.ts +55 -14
- package/src/harness/browser-coverage.test.ts +17 -0
- package/src/harness/browser-coverage.ts +19 -1
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. */
|
|
@@ -106,6 +106,8 @@ async function harvestInner(c, config) {
|
|
|
106
106
|
continue;
|
|
107
107
|
if (info.map === undefined)
|
|
108
108
|
info.map = await sourceMapFor(info, url);
|
|
109
|
+
if (!info.map && isDocumentScriptPath(url.pathname))
|
|
110
|
+
continue; // inline script in a page
|
|
109
111
|
const files = info.map
|
|
110
112
|
? mapToOriginal(info.map, generated)
|
|
111
113
|
: new Map([[url.pathname, generated]]);
|
package/dist/coverage.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
|
3
3
|
export { COVERAGE_CONTAINER_DIR };
|
|
4
4
|
/** Where `node()` mounts its hook inside the container. */
|
|
5
5
|
export declare const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
6
|
+
/** The report `node()` writes when nothing has run yet on a branch. */
|
|
7
|
+
export declare const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
|
|
6
8
|
/** Prefix of the per-process sockets the node hook answers on, relative
|
|
7
9
|
* to the coverage dir: `.ctl-<pid>`. */
|
|
8
10
|
export declare const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
@@ -97,16 +99,22 @@ export declare function appendEnvFlag(env: Readonly<Record<string, string>> | un
|
|
|
97
99
|
* wrapper dumped the wrapper and silently never the server (reported by
|
|
98
100
|
* a user 2026-08-27). At capture spectest asks every live socket and
|
|
99
101
|
* unlinks the stale ones. Unref'd, so a short-lived process still exits.
|
|
102
|
+
* Main thread only: a worker thread inherits `NODE_OPTIONS`, and tsx's
|
|
103
|
+
* ESM loader thread bound the shared per-pid path last, so every
|
|
104
|
+
* `--import tsx` server answered with the loader's coverage — the app's
|
|
105
|
+
* main thread was never dumped (found in the same user's first full run).
|
|
100
106
|
* No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
|
|
101
107
|
* Temporal worker, restarts nodemon), a socket is nobody's.
|
|
102
108
|
*/
|
|
103
109
|
export declare const NODE_COVERAGE_HOOK: string;
|
|
104
110
|
/**
|
|
105
|
-
* Coverage for a Node service
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
111
|
+
* Coverage for a Node service — a long-lived server, or a container whose
|
|
112
|
+
* node processes are short-lived CLIs run by `ctx.exec`. Sets
|
|
113
|
+
* `NODE_V8_COVERAGE` to the coverage directory (every node process in the
|
|
114
|
+
* container then writes V8 coverage JSON when it exits) and `--require`s
|
|
115
|
+
* a hook that lets spectest ask every live node process for a dump at
|
|
116
|
+
* capture time — the server, and any wrapper it sits behind (`pnpm exec`,
|
|
117
|
+
* `tsx`). Nothing
|
|
110
118
|
* for the app to write. Each dump is compacted to the app's own scripts
|
|
111
119
|
* at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
|
|
112
120
|
* `sourceMappingURL` next to the file) Node records the map in the
|
|
@@ -120,7 +128,8 @@ export declare function node(): CoverageAdapter;
|
|
|
120
128
|
* answered with an error throws.
|
|
121
129
|
*/
|
|
122
130
|
export declare function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Promise<number>;
|
|
123
|
-
/** A script is the app's own when it is a file outside node_modules
|
|
131
|
+
/** A script is the app's own when it is a file outside node_modules —
|
|
132
|
+
* and not our hook, which every dump would otherwise carry. */
|
|
124
133
|
export declare function isAppScriptUrl(url: string): boolean;
|
|
125
134
|
/**
|
|
126
135
|
* Compact one V8 coverage document to the app's own scripts. What Node
|
package/dist/coverage.js
CHANGED
|
@@ -29,6 +29,8 @@ import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
|
29
29
|
export { COVERAGE_CONTAINER_DIR };
|
|
30
30
|
/** Where `node()` mounts its hook inside the container. */
|
|
31
31
|
export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
32
|
+
/** The report `node()` writes when nothing has run yet on a branch. */
|
|
33
|
+
export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
|
|
32
34
|
/** Prefix of the per-process sockets the node hook answers on, relative
|
|
33
35
|
* to the coverage dir: `.ctl-<pid>`. */
|
|
34
36
|
export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
@@ -107,12 +109,20 @@ export function appendEnvFlag(env, name, value) {
|
|
|
107
109
|
* wrapper dumped the wrapper and silently never the server (reported by
|
|
108
110
|
* a user 2026-08-27). At capture spectest asks every live socket and
|
|
109
111
|
* unlinks the stale ones. Unref'd, so a short-lived process still exits.
|
|
112
|
+
* Main thread only: a worker thread inherits `NODE_OPTIONS`, and tsx's
|
|
113
|
+
* ESM loader thread bound the shared per-pid path last, so every
|
|
114
|
+
* `--import tsx` server answered with the loader's coverage — the app's
|
|
115
|
+
* main thread was never dumped (found in the same user's first full run).
|
|
110
116
|
* No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
|
|
111
117
|
* Temporal worker, restarts nodemon), a socket is nobody's.
|
|
112
118
|
*/
|
|
113
119
|
export const NODE_COVERAGE_HOOK = `"use strict";
|
|
114
120
|
// spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
|
|
115
|
-
|
|
121
|
+
// Main thread only: worker threads inherit NODE_OPTIONS and would bind
|
|
122
|
+
// the same per-pid socket last — tsx's ESM loader runs on one, and it
|
|
123
|
+
// stole the socket from every \`--import tsx\` server, so a dump was the
|
|
124
|
+
// loader's isolate and never the app's.
|
|
125
|
+
if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread) {
|
|
116
126
|
const net = require("node:net");
|
|
117
127
|
const fs = require("node:fs");
|
|
118
128
|
const v8 = require("node:v8");
|
|
@@ -142,11 +152,13 @@ if (process.env.NODE_V8_COVERAGE) {
|
|
|
142
152
|
}
|
|
143
153
|
`;
|
|
144
154
|
/**
|
|
145
|
-
* Coverage for a Node service
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
155
|
+
* Coverage for a Node service — a long-lived server, or a container whose
|
|
156
|
+
* node processes are short-lived CLIs run by `ctx.exec`. Sets
|
|
157
|
+
* `NODE_V8_COVERAGE` to the coverage directory (every node process in the
|
|
158
|
+
* container then writes V8 coverage JSON when it exits) and `--require`s
|
|
159
|
+
* a hook that lets spectest ask every live node process for a dump at
|
|
160
|
+
* capture time — the server, and any wrapper it sits behind (`pnpm exec`,
|
|
161
|
+
* `tsx`). Nothing
|
|
150
162
|
* for the app to write. Each dump is compacted to the app's own scripts
|
|
151
163
|
* at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
|
|
152
164
|
* `sourceMappingURL` next to the file) Node records the map in the
|
|
@@ -165,11 +177,20 @@ export function node() {
|
|
|
165
177
|
};
|
|
166
178
|
},
|
|
167
179
|
async capture(ctx) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
180
|
+
// Two shapes, one adapter: a long-lived server answers its socket
|
|
181
|
+
// here; a service whose node processes are short-lived (a CLI run
|
|
182
|
+
// by `ctx.exec` from a `sleep infinity` container) has no socket to
|
|
183
|
+
// answer and its coverage is the exit-time dumps already on disk.
|
|
184
|
+
// So no live process is not an error. No report at all — nothing
|
|
185
|
+
// has run yet on this branch — is recorded as an empty document
|
|
186
|
+
// rather than failed: an empty report is a report. A server whose
|
|
187
|
+
// hook never loaded (NODE_OPTIONS not reaching it) then shows as
|
|
188
|
+
// empty reports after bring-up, which the boot log warns about.
|
|
189
|
+
await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
|
|
172
190
|
await compactV8Reports(ctx.reportDir);
|
|
191
|
+
if (!(await hasV8Reports(ctx.reportDir))) {
|
|
192
|
+
await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
|
|
193
|
+
}
|
|
173
194
|
},
|
|
174
195
|
};
|
|
175
196
|
}
|
|
@@ -227,12 +248,29 @@ export async function dumpAllNodeProcesses(dir, signal) {
|
|
|
227
248
|
}
|
|
228
249
|
return live;
|
|
229
250
|
}
|
|
251
|
+
/** True when `dir` holds at least one `coverage-*.json` other than the
|
|
252
|
+
* empty placeholder. */
|
|
253
|
+
async function hasV8Reports(dir) {
|
|
254
|
+
const fs = await import("node:fs/promises");
|
|
255
|
+
try {
|
|
256
|
+
return (await fs.readdir(dir)).some((n) => n.startsWith("coverage-") && n.endsWith(".json") && n !== NODE_COVERAGE_EMPTY_REPORT);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
230
262
|
/** V8 files already compacted, by path. Module memory: forks with the
|
|
231
263
|
* environment, so a child never re-parses its ancestors' dumps. */
|
|
232
264
|
const COMPACTED_V8_REPORTS = new Set();
|
|
233
|
-
/** A script is the app's own when it is a file outside node_modules
|
|
265
|
+
/** A script is the app's own when it is a file outside node_modules —
|
|
266
|
+
* and not our hook, which every dump would otherwise carry. */
|
|
234
267
|
export function isAppScriptUrl(url) {
|
|
235
|
-
return url.startsWith("file://") &&
|
|
268
|
+
return (url.startsWith("file://") &&
|
|
269
|
+
!url.includes("/node_modules/") &&
|
|
270
|
+
// Package managers run from a cache, not node_modules: corepack's pnpm
|
|
271
|
+
// is `/root/.cache/node/corepack/…`, 2 MiB of dump per `pnpm run`.
|
|
272
|
+
!url.includes("/.cache/") &&
|
|
273
|
+
!url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop()));
|
|
236
274
|
}
|
|
237
275
|
/**
|
|
238
276
|
* Compact one V8 coverage document to the app's own scripts. What Node
|
|
@@ -51,6 +51,14 @@ export declare function normalizeSourcePath(src: string, sourceRoot?: string): s
|
|
|
51
51
|
/** Sources nobody's change can be attributed to: dependencies and bundler
|
|
52
52
|
* runtime shims. */
|
|
53
53
|
export declare function isForeignSource(path: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* A script with no source map is attributed to its served path — unless
|
|
56
|
+
* that path is a page, not a file: an inline `<script>` in an HTML
|
|
57
|
+
* document reports the document's URL (`/`, `/projects/123`), and no
|
|
58
|
+
* repository file can ever be named by it. Extension-less paths are
|
|
59
|
+
* treated as pages.
|
|
60
|
+
*/
|
|
61
|
+
export declare function isDocumentScriptPath(pathname: string): boolean;
|
|
54
62
|
/**
|
|
55
63
|
* Decode a source map document. Index maps (`sections`) are not
|
|
56
64
|
* supported and decode to `null`, as does anything malformed — the
|
|
@@ -104,6 +104,10 @@ export function normalizeSourcePath(src, sourceRoot) {
|
|
|
104
104
|
s = m[1];
|
|
105
105
|
s = s.replace(/^\/@fs\//, "/");
|
|
106
106
|
s = s.replace(/\?.*$/, "");
|
|
107
|
+
// Turbopack (Next.js) roots every source at the `[project]` token —
|
|
108
|
+
// `/[project]/dashboard/app/page.tsx` — which is the workspace root,
|
|
109
|
+
// i.e. repo-relative once stripped.
|
|
110
|
+
s = s.replace(/^\/?\[project\]\//, "");
|
|
107
111
|
while (s.startsWith("./"))
|
|
108
112
|
s = s.slice(2);
|
|
109
113
|
return s;
|
|
@@ -117,7 +121,20 @@ export function isForeignSource(path) {
|
|
|
117
121
|
path.startsWith("(webpack)") ||
|
|
118
122
|
path.startsWith("\0") ||
|
|
119
123
|
path.startsWith("vite/") ||
|
|
120
|
-
path.startsWith("@vite/")
|
|
124
|
+
path.startsWith("@vite/") ||
|
|
125
|
+
path.startsWith("[turbopack]/") ||
|
|
126
|
+
path.startsWith("/[turbopack]/"));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A script with no source map is attributed to its served path — unless
|
|
130
|
+
* that path is a page, not a file: an inline `<script>` in an HTML
|
|
131
|
+
* document reports the document's URL (`/`, `/projects/123`), and no
|
|
132
|
+
* repository file can ever be named by it. Extension-less paths are
|
|
133
|
+
* treated as pages.
|
|
134
|
+
*/
|
|
135
|
+
export function isDocumentScriptPath(pathname) {
|
|
136
|
+
const last = pathname.split("/").pop() ?? "";
|
|
137
|
+
return !last.includes(".");
|
|
121
138
|
}
|
|
122
139
|
/**
|
|
123
140
|
* Decode a source map document. Index maps (`sections`) are not
|
package/package.json
CHANGED
package/src/browser-coverage.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type DecodedSourceMap,
|
|
26
26
|
type FileCounts,
|
|
27
27
|
decodeSourceMap,
|
|
28
|
+
isDocumentScriptPath,
|
|
28
29
|
lcovDocument,
|
|
29
30
|
mapToOriginal,
|
|
30
31
|
mergeFileCounts,
|
|
@@ -137,6 +138,7 @@ async function harvestInner(c: Collector, config: BrowserCoverageConfig): Promis
|
|
|
137
138
|
const generated = rangesToLineCounts(info.source, script.functions);
|
|
138
139
|
if (generated.size === 0) continue;
|
|
139
140
|
if (info.map === undefined) info.map = await sourceMapFor(info, url);
|
|
141
|
+
if (!info.map && isDocumentScriptPath(url.pathname)) continue; // inline script in a page
|
|
140
142
|
const files: FileCounts = info.map
|
|
141
143
|
? mapToOriginal(info.map, generated)
|
|
142
144
|
: new Map([[url.pathname, generated]]);
|
package/src/coverage.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import {
|
|
7
7
|
NODE_COVERAGE_HOOK,
|
|
8
|
+
NODE_COVERAGE_EMPTY_REPORT,
|
|
8
9
|
NODE_COVERAGE_HOOK_PATH,
|
|
9
10
|
NODE_COVERAGE_SOCKET_PREFIX,
|
|
10
11
|
applyCoverageAdapters,
|
|
@@ -76,6 +77,7 @@ describe("compactV8Document", () => {
|
|
|
76
77
|
{ scriptId: "1", url: "file:///app/src/a.ts", functions: [] },
|
|
77
78
|
{ scriptId: "2", url: "file:///app/node_modules/x/index.js", functions: [] },
|
|
78
79
|
{ scriptId: "3", url: "node:fs", functions: [] },
|
|
80
|
+
{ scriptId: "5", url: "file:///root/.cache/node/corepack/v1/pnpm/10.0.0/dist/pnpm.cjs", functions: [] },
|
|
79
81
|
{ scriptId: "4", url: "", functions: [] },
|
|
80
82
|
],
|
|
81
83
|
"source-map-cache": {
|
|
@@ -157,7 +159,7 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
157
159
|
return (await fs.readdir(dir)).filter((n) => n.startsWith(NODE_COVERAGE_SOCKET_PREFIX)).sort();
|
|
158
160
|
}
|
|
159
161
|
async function reportsIn(dir: string): Promise<string[]> {
|
|
160
|
-
return (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
|
|
162
|
+
return (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-") && n.endsWith(".json"));
|
|
161
163
|
}
|
|
162
164
|
async function start(dir: string, entry: string, expectSockets: number): Promise<ChildProcess> {
|
|
163
165
|
const hook = path.join(dir, "hook.cjs");
|
|
@@ -231,25 +233,79 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
231
233
|
}
|
|
232
234
|
});
|
|
233
235
|
|
|
236
|
+
test("short-lived CLI processes: exit dumps are the coverage, no live process needed", async () => {
|
|
237
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
238
|
+
const hook = path.join(dir, "coverage-hook.cjs");
|
|
239
|
+
await fs.writeFile(hook, NODE_COVERAGE_HOOK);
|
|
240
|
+
await fs.writeFile(path.join(dir, "cli.js"), "process.stdout.write('v1');\n");
|
|
241
|
+
// Nothing has run yet: an empty report, not a failure.
|
|
242
|
+
await node().capture!(fakeCtx(dir));
|
|
243
|
+
expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
|
|
244
|
+
// Two CLI runs, each its own process, each dumping at exit.
|
|
245
|
+
for (let i = 0; i < 2; i++) {
|
|
246
|
+
const cli = spawn("node", [path.join(dir, "cli.js")], {
|
|
247
|
+
env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
|
|
248
|
+
stdio: "ignore",
|
|
249
|
+
});
|
|
250
|
+
expect(await new Promise<number | null>((r) => cli.on("exit", r))).toBe(0);
|
|
251
|
+
}
|
|
252
|
+
expect((await socketsIn(dir)).length).toBe(0);
|
|
253
|
+
await node().capture!(fakeCtx(dir));
|
|
254
|
+
const reports = (await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT);
|
|
255
|
+
expect(reports.length).toBe(2);
|
|
256
|
+
const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as { result: { url: string }[] };
|
|
257
|
+
expect(doc.result.map((x) => x.url)).toEqual([`file://${path.join(dir, "cli.js")}`]); // compacted
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("a worker thread does not bind the socket; the main thread's dump is the app's", async () => {
|
|
261
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
262
|
+
const hook = path.join(dir, "coverage-hook.cjs");
|
|
263
|
+
await fs.writeFile(hook, NODE_COVERAGE_HOOK);
|
|
264
|
+
await fs.writeFile(
|
|
265
|
+
path.join(dir, "server.js"),
|
|
266
|
+
"function appWork() { return 1 } appWork();\n" +
|
|
267
|
+
"new (require('node:worker_threads').Worker)('setInterval(() => {}, 1000)', { eval: true });\n" +
|
|
268
|
+
SERVER_JS,
|
|
269
|
+
);
|
|
270
|
+
const child = spawn("node", [path.join(dir, "server.js")], {
|
|
271
|
+
env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
|
|
272
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
273
|
+
});
|
|
274
|
+
await new Promise<void>((resolve) => child.stdout!.once("data", () => resolve()));
|
|
275
|
+
try {
|
|
276
|
+
await new Promise((r) => setTimeout(r, 300)); // let the worker thread start
|
|
277
|
+
expect((await socketsIn(dir)).length).toBe(1);
|
|
278
|
+
await node().capture!(fakeCtx(dir));
|
|
279
|
+
const reports = (await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT);
|
|
280
|
+
expect(reports.length).toBe(1);
|
|
281
|
+
const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as { result: { url: string }[] };
|
|
282
|
+
expect(doc.result.some((s) => s.url.endsWith("/server.js"))).toBe(true);
|
|
283
|
+
} finally {
|
|
284
|
+
child.kill("SIGKILL");
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
234
288
|
test("a stale socket left by a killed process is removed at capture", async () => {
|
|
235
289
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
236
290
|
const first = await start(dir, "server.js", 1);
|
|
237
291
|
first.kill("SIGKILL");
|
|
238
292
|
await new Promise<void>((resolve) => first.on("exit", () => resolve()));
|
|
239
293
|
expect((await socketsIn(dir)).length).toBe(1); // stale
|
|
240
|
-
await
|
|
294
|
+
await node().capture!(fakeCtx(dir));
|
|
241
295
|
expect((await socketsIn(dir)).length).toBe(0); // cleaned up
|
|
242
296
|
const second = await start(dir, "server.js", 1);
|
|
243
297
|
try {
|
|
244
298
|
await node().capture!(fakeCtx(dir));
|
|
245
|
-
expect((await reportsIn(dir)).length).toBe(1);
|
|
299
|
+
expect((await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT).length).toBe(1);
|
|
246
300
|
} finally {
|
|
247
301
|
second.kill("SIGKILL");
|
|
248
302
|
}
|
|
249
303
|
});
|
|
250
304
|
|
|
251
|
-
test("no
|
|
305
|
+
test("no process and no dump: an empty report is written, once", async () => {
|
|
252
306
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
253
|
-
await
|
|
307
|
+
await node().capture!(fakeCtx(dir));
|
|
308
|
+
await node().capture!(fakeCtx(dir));
|
|
309
|
+
expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
|
|
254
310
|
});
|
|
255
311
|
});
|
package/src/coverage.ts
CHANGED
|
@@ -34,6 +34,9 @@ export { COVERAGE_CONTAINER_DIR };
|
|
|
34
34
|
/** Where `node()` mounts its hook inside the container. */
|
|
35
35
|
export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
36
36
|
|
|
37
|
+
/** The report `node()` writes when nothing has run yet on a branch. */
|
|
38
|
+
export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
|
|
39
|
+
|
|
37
40
|
/** Prefix of the per-process sockets the node hook answers on, relative
|
|
38
41
|
* to the coverage dir: `.ctl-<pid>`. */
|
|
39
42
|
export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
@@ -190,12 +193,20 @@ export function appendEnvFlag(
|
|
|
190
193
|
* wrapper dumped the wrapper and silently never the server (reported by
|
|
191
194
|
* a user 2026-08-27). At capture spectest asks every live socket and
|
|
192
195
|
* unlinks the stale ones. Unref'd, so a short-lived process still exits.
|
|
196
|
+
* Main thread only: a worker thread inherits `NODE_OPTIONS`, and tsx's
|
|
197
|
+
* ESM loader thread bound the shared per-pid path last, so every
|
|
198
|
+
* `--import tsx` server answered with the loader's coverage — the app's
|
|
199
|
+
* main thread was never dumped (found in the same user's first full run).
|
|
193
200
|
* No signal is used: signals are claimed by frameworks (SIGUSR2 stops a
|
|
194
201
|
* Temporal worker, restarts nodemon), a socket is nobody's.
|
|
195
202
|
*/
|
|
196
203
|
export const NODE_COVERAGE_HOOK = `"use strict";
|
|
197
204
|
// spectest coverage hook (coverage.node() adapter). See \`spectest docs /services/coverage\`.
|
|
198
|
-
|
|
205
|
+
// Main thread only: worker threads inherit NODE_OPTIONS and would bind
|
|
206
|
+
// the same per-pid socket last — tsx's ESM loader runs on one, and it
|
|
207
|
+
// stole the socket from every \`--import tsx\` server, so a dump was the
|
|
208
|
+
// loader's isolate and never the app's.
|
|
209
|
+
if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread) {
|
|
199
210
|
const net = require("node:net");
|
|
200
211
|
const fs = require("node:fs");
|
|
201
212
|
const v8 = require("node:v8");
|
|
@@ -226,11 +237,13 @@ if (process.env.NODE_V8_COVERAGE) {
|
|
|
226
237
|
`;
|
|
227
238
|
|
|
228
239
|
/**
|
|
229
|
-
* Coverage for a Node service
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
240
|
+
* Coverage for a Node service — a long-lived server, or a container whose
|
|
241
|
+
* node processes are short-lived CLIs run by `ctx.exec`. Sets
|
|
242
|
+
* `NODE_V8_COVERAGE` to the coverage directory (every node process in the
|
|
243
|
+
* container then writes V8 coverage JSON when it exits) and `--require`s
|
|
244
|
+
* a hook that lets spectest ask every live node process for a dump at
|
|
245
|
+
* capture time — the server, and any wrapper it sits behind (`pnpm exec`,
|
|
246
|
+
* `tsx`). Nothing
|
|
234
247
|
* for the app to write. Each dump is compacted to the app's own scripts
|
|
235
248
|
* at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
|
|
236
249
|
* `sourceMappingURL` next to the file) Node records the map in the
|
|
@@ -249,13 +262,20 @@ export function node(): CoverageAdapter {
|
|
|
249
262
|
};
|
|
250
263
|
},
|
|
251
264
|
async capture(ctx) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
265
|
+
// Two shapes, one adapter: a long-lived server answers its socket
|
|
266
|
+
// here; a service whose node processes are short-lived (a CLI run
|
|
267
|
+
// by `ctx.exec` from a `sleep infinity` container) has no socket to
|
|
268
|
+
// answer and its coverage is the exit-time dumps already on disk.
|
|
269
|
+
// So no live process is not an error. No report at all — nothing
|
|
270
|
+
// has run yet on this branch — is recorded as an empty document
|
|
271
|
+
// rather than failed: an empty report is a report. A server whose
|
|
272
|
+
// hook never loaded (NODE_OPTIONS not reaching it) then shows as
|
|
273
|
+
// empty reports after bring-up, which the boot log warns about.
|
|
274
|
+
await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
|
|
258
275
|
await compactV8Reports(ctx.reportDir);
|
|
276
|
+
if (!(await hasV8Reports(ctx.reportDir))) {
|
|
277
|
+
await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
|
|
278
|
+
}
|
|
259
279
|
},
|
|
260
280
|
};
|
|
261
281
|
}
|
|
@@ -310,13 +330,34 @@ export async function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Pr
|
|
|
310
330
|
return live;
|
|
311
331
|
}
|
|
312
332
|
|
|
333
|
+
/** True when `dir` holds at least one `coverage-*.json` other than the
|
|
334
|
+
* empty placeholder. */
|
|
335
|
+
async function hasV8Reports(dir: string): Promise<boolean> {
|
|
336
|
+
const fs = await import("node:fs/promises");
|
|
337
|
+
try {
|
|
338
|
+
return (await fs.readdir(dir)).some(
|
|
339
|
+
(n) => n.startsWith("coverage-") && n.endsWith(".json") && n !== NODE_COVERAGE_EMPTY_REPORT,
|
|
340
|
+
);
|
|
341
|
+
} catch {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
313
346
|
/** V8 files already compacted, by path. Module memory: forks with the
|
|
314
347
|
* environment, so a child never re-parses its ancestors' dumps. */
|
|
315
348
|
const COMPACTED_V8_REPORTS = new Set<string>();
|
|
316
349
|
|
|
317
|
-
/** A script is the app's own when it is a file outside node_modules
|
|
350
|
+
/** A script is the app's own when it is a file outside node_modules —
|
|
351
|
+
* and not our hook, which every dump would otherwise carry. */
|
|
318
352
|
export function isAppScriptUrl(url: string): boolean {
|
|
319
|
-
return
|
|
353
|
+
return (
|
|
354
|
+
url.startsWith("file://") &&
|
|
355
|
+
!url.includes("/node_modules/") &&
|
|
356
|
+
// Package managers run from a cache, not node_modules: corepack's pnpm
|
|
357
|
+
// is `/root/.cache/node/corepack/…`, 2 MiB of dump per `pnpm run`.
|
|
358
|
+
!url.includes("/.cache/") &&
|
|
359
|
+
!url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop())
|
|
360
|
+
);
|
|
320
361
|
}
|
|
321
362
|
|
|
322
363
|
/**
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
decodeSourceMap,
|
|
4
4
|
decodeVlq,
|
|
5
|
+
isDocumentScriptPath,
|
|
5
6
|
isForeignSource,
|
|
6
7
|
lcovDocument,
|
|
7
8
|
mapToOriginal,
|
|
@@ -92,3 +93,19 @@ describe("serviceForHost", () => {
|
|
|
92
93
|
expect(serviceForHost("example.com", services, table)).toBeUndefined();
|
|
93
94
|
});
|
|
94
95
|
});
|
|
96
|
+
|
|
97
|
+
describe("turbopack + inline scripts", () => {
|
|
98
|
+
test("[project] root token is stripped, [turbopack] shims are foreign", () => {
|
|
99
|
+
expect(normalizeSourcePath("/[project]/dashboard/app/page.tsx")).toBe("dashboard/app/page.tsx");
|
|
100
|
+
expect(normalizeSourcePath("[project]/dashboard/app/page.tsx")).toBe("dashboard/app/page.tsx");
|
|
101
|
+
expect(isForeignSource("/[turbopack]/browser/runtime/base/build-base.ts")).toBe(true);
|
|
102
|
+
expect(isForeignSource("[turbopack]/shared/runtime-utils.ts")).toBe(true);
|
|
103
|
+
expect(isForeignSource("dashboard/app/page.tsx")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
test("an unmapped script at a page URL is a document script, a .js file is not", () => {
|
|
106
|
+
expect(isDocumentScriptPath("/")).toBe(true);
|
|
107
|
+
expect(isDocumentScriptPath("/projects/proj_1")).toBe(true);
|
|
108
|
+
expect(isDocumentScriptPath("/_next/static/chunks/main.js")).toBe(false);
|
|
109
|
+
expect(isDocumentScriptPath("/app.js")).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -136,6 +136,10 @@ export function normalizeSourcePath(src: string, sourceRoot?: string): string {
|
|
|
136
136
|
if (m) s = m[1];
|
|
137
137
|
s = s.replace(/^\/@fs\//, "/");
|
|
138
138
|
s = s.replace(/\?.*$/, "");
|
|
139
|
+
// Turbopack (Next.js) roots every source at the `[project]` token —
|
|
140
|
+
// `/[project]/dashboard/app/page.tsx` — which is the workspace root,
|
|
141
|
+
// i.e. repo-relative once stripped.
|
|
142
|
+
s = s.replace(/^\/?\[project\]\//, "");
|
|
139
143
|
while (s.startsWith("./")) s = s.slice(2);
|
|
140
144
|
return s;
|
|
141
145
|
}
|
|
@@ -150,10 +154,24 @@ export function isForeignSource(path: string): boolean {
|
|
|
150
154
|
path.startsWith("(webpack)") ||
|
|
151
155
|
path.startsWith("\0") ||
|
|
152
156
|
path.startsWith("vite/") ||
|
|
153
|
-
path.startsWith("@vite/")
|
|
157
|
+
path.startsWith("@vite/") ||
|
|
158
|
+
path.startsWith("[turbopack]/") ||
|
|
159
|
+
path.startsWith("/[turbopack]/")
|
|
154
160
|
);
|
|
155
161
|
}
|
|
156
162
|
|
|
163
|
+
/**
|
|
164
|
+
* A script with no source map is attributed to its served path — unless
|
|
165
|
+
* that path is a page, not a file: an inline `<script>` in an HTML
|
|
166
|
+
* document reports the document's URL (`/`, `/projects/123`), and no
|
|
167
|
+
* repository file can ever be named by it. Extension-less paths are
|
|
168
|
+
* treated as pages.
|
|
169
|
+
*/
|
|
170
|
+
export function isDocumentScriptPath(pathname: string): boolean {
|
|
171
|
+
const last = pathname.split("/").pop() ?? "";
|
|
172
|
+
return !last.includes(".");
|
|
173
|
+
}
|
|
174
|
+
|
|
157
175
|
/**
|
|
158
176
|
* Decode a source map document. Index maps (`sections`) are not
|
|
159
177
|
* supported and decode to `null`, as does anything malformed — the
|