@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.
@@ -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]]);
@@ -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. Sets `NODE_V8_COVERAGE` to the coverage
106
- * directory (every node process in the container then writes V8
107
- * coverage JSON when it exits) and `--require`s a hook that lets spectest
108
- * ask every live node process for a dump at capture time — the server,
109
- * and any wrapper it sits behind (`pnpm exec`, `tsx`). Nothing
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
- if (process.env.NODE_V8_COVERAGE) {
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. Sets `NODE_V8_COVERAGE` to the coverage
146
- * directory (every node process in the container then writes V8
147
- * coverage JSON when it exits) and `--require`s a hook that lets spectest
148
- * ask every live node process for a dump at capture time — the server,
149
- * and any wrapper it sits behind (`pnpm exec`, `tsx`). Nothing
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
- const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
169
- if (live === 0) {
170
- throw new Error(`no node process is serving a ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_SOCKET_PREFIX}<pid> socket — does the service run node with the service's env (NODE_OPTIONS), and is it still alive?`);
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://") && !url.includes("/node_modules/");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.59.2",
3
+ "version": "0.59.4",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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]]);
@@ -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 expect(node().capture!(fakeCtx(dir))).rejects.toThrow(/no node process is serving/);
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 server: capture fails naming the socket", async () => {
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 expect(node().capture!(fakeCtx(dir))).rejects.toThrow(/no node process is serving/);
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
- if (process.env.NODE_V8_COVERAGE) {
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. Sets `NODE_V8_COVERAGE` to the coverage
230
- * directory (every node process in the container then writes V8
231
- * coverage JSON when it exits) and `--require`s a hook that lets spectest
232
- * ask every live node process for a dump at capture time — the server,
233
- * and any wrapper it sits behind (`pnpm exec`, `tsx`). Nothing
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
- const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
253
- if (live === 0) {
254
- throw new Error(
255
- `no node process is serving a ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_SOCKET_PREFIX}<pid> socket — does the service run node with the service's env (NODE_OPTIONS), and is it still alive?`,
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 url.startsWith("file://") && !url.includes("/node_modules/");
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