@specific.dev/spectest 0.81.0 → 0.82.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.
@@ -4,27 +4,21 @@ import { promises as fs } from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import {
7
+ NODE_COVERAGE_DIR_ENV,
7
8
  NODE_COVERAGE_HOOK,
8
9
  NODE_COVERAGE_EMPTY_REPORT,
9
10
  NODE_COVERAGE_HOOK_PATH,
11
+ NODE_COVERAGE_SCRIPTS_SUBDIR,
12
+ NODE_COVERAGE_MARKERS_SUBDIR,
10
13
  NODE_COVERAGE_SOCKET_PREFIX,
11
- SOURCE_MAP_HASH_KEY,
12
- SOURCE_MAP_REF_KEY,
13
14
  applyCoverageAdapters,
14
15
  appendEnvFlag,
15
- attachSourceMaps,
16
- lineLengthsOf,
17
- resolveSourceMapFromContainer,
18
- sourceMappingUrlOf,
19
16
  browser,
20
17
  command,
21
18
  coverageReportsMode,
22
- compactV8Document,
19
+ isNodeDumpName,
23
20
  node,
24
- prepareV8Reports,
25
- removeShippedV8Reports,
26
21
  resetNodeCoverageMemory,
27
- scriptExecuted,
28
22
  validateCoverage,
29
23
  type CoverageCaptureContext,
30
24
  } from "./coverage.js";
@@ -68,10 +62,11 @@ describe("validateCoverage", () => {
68
62
  });
69
63
 
70
64
  describe("applyCoverageAdapters", () => {
71
- test("node: appends NODE_OPTIONS, sets NODE_V8_COVERAGE, mounts the hook", () => {
65
+ test("node: appends NODE_OPTIONS, sets the coverage dir env, mounts the hook — never NODE_V8_COVERAGE", () => {
72
66
  const out = applyCoverageAdapters("api", { ...base, coverage: { adapters: [node()] } });
73
67
  expect(out.env?.NODE_OPTIONS).toBe(`--max-old-space-size=512 --require ${NODE_COVERAGE_HOOK_PATH}`);
74
- expect(out.env?.NODE_V8_COVERAGE).toBe("/spectest/coverage");
68
+ expect(out.env?.[NODE_COVERAGE_DIR_ENV]).toBe("/spectest/coverage");
69
+ expect(out.env?.NODE_V8_COVERAGE).toBeUndefined();
75
70
  expect(out.files?.map((f) => f.path)).toEqual([NODE_COVERAGE_HOOK_PATH]);
76
71
  expect(out.files?.[0]?.content).toBe(NODE_COVERAGE_HOOK);
77
72
  });
@@ -99,92 +94,6 @@ describe("applyCoverageAdapters", () => {
99
94
  });
100
95
  });
101
96
 
102
- const ran = { functions: [{ functionName: "", ranges: [{ startOffset: 0, endOffset: 10, count: 1 }] }] };
103
- const idle = { functions: [{ functionName: "", ranges: [{ startOffset: 0, endOffset: 10, count: 0 }] }] };
104
-
105
- describe("compactV8Document", () => {
106
- const doc = {
107
- result: [
108
- { scriptId: "1", url: "file:///app/src/a.ts", ...ran },
109
- { scriptId: "2", url: "file:///app/node_modules/x/index.js", ...ran },
110
- { scriptId: "3", url: "node:fs", ...ran },
111
- { scriptId: "5", url: "file:///root/.cache/node/corepack/v1/pnpm/10.0.0/dist/pnpm.cjs", ...ran },
112
- { scriptId: "6", url: "file:///app/src/idle.ts", ...idle },
113
- { scriptId: "4", url: "", ...ran },
114
- ],
115
- "source-map-cache": {
116
- "file:///app/src/a.ts": { lineLengths: [1], data: { mappings: "AAAA", sources: ["a.ts"], sourcesContent: ["x"] }, url: null },
117
- "file:///app/src/idle.ts": { lineLengths: [1], data: { mappings: "AAAA", sources: ["idle.ts"] }, url: null },
118
- "file:///app/node_modules/x/index.js": { lineLengths: [1], data: { mappings: "AAAA" }, url: null },
119
- },
120
- };
121
- type Cache = Record<string, Record<string, unknown>>;
122
-
123
- test("scriptExecuted: any range count above zero", () => {
124
- expect(scriptExecuted(ran)).toBe(true);
125
- expect(scriptExecuted(idle)).toBe(false);
126
- expect(scriptExecuted({})).toBe(false);
127
- });
128
- test("keeps app scripts that ran and their maps, drops the rest and sourcesContent", () => {
129
- const out = compactV8Document(doc) as typeof doc;
130
- expect(out.result.map((s) => s.url)).toEqual(["file:///app/src/a.ts"]);
131
- const cache = out["source-map-cache"] as unknown as Cache;
132
- expect(Object.keys(cache)).toEqual(["file:///app/src/a.ts"]);
133
- expect(cache["file:///app/src/a.ts"].data).toEqual({ mappings: "AAAA", sources: ["a.ts"] });
134
- expect(cache["file:///app/src/a.ts"][SOURCE_MAP_HASH_KEY]).toMatch(/^sha256:[0-9a-f]{64}$/);
135
- expect(cache["file:///app/src/a.ts"][SOURCE_MAP_REF_KEY]).toBeUndefined();
136
- });
137
- test("a map shipped earlier on the branch becomes a stub that refers to its hash", () => {
138
- const shipped = new Map<string, string>();
139
- const first = compactV8Document(doc, shipped) as typeof doc;
140
- const hash = (first["source-map-cache"] as unknown as Cache)["file:///app/src/a.ts"][SOURCE_MAP_HASH_KEY];
141
- expect(shipped.get("file:///app/src/a.ts")).toBe(hash as string);
142
- const second = compactV8Document(doc, shipped) as typeof doc;
143
- expect((second["source-map-cache"] as unknown as Cache)["file:///app/src/a.ts"]).toEqual({ [SOURCE_MAP_REF_KEY]: hash });
144
- // The hash is over the compacted entry, so the same map with its
145
- // sources dropped is the same map; a changed map ships again.
146
- const changed = {
147
- ...doc,
148
- "source-map-cache": { "file:///app/src/a.ts": { lineLengths: [2], data: { mappings: "AAAC", sources: ["a.ts"] }, url: null } },
149
- };
150
- const third = compactV8Document(changed, shipped) as typeof doc;
151
- const entry = (third["source-map-cache"] as unknown as Cache)["file:///app/src/a.ts"];
152
- expect(entry[SOURCE_MAP_REF_KEY]).toBeUndefined();
153
- expect(entry[SOURCE_MAP_HASH_KEY]).not.toBe(hash);
154
- });
155
- test("a dump with no app script keeps an empty result and no cache", () => {
156
- const out = compactV8Document({ result: [doc.result[2]], "source-map-cache": doc["source-map-cache"] });
157
- expect(out.result).toEqual([]);
158
- expect("source-map-cache" in out).toBe(false);
159
- });
160
- test("prepareV8Reports compacts coverage-*.json once, ships maps once, and the next capture removes them", async () => {
161
- resetNodeCoverageMemory();
162
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
163
- await fs.writeFile(path.join(dir, "coverage-1-2-0.json"), JSON.stringify(doc));
164
- await fs.writeFile(path.join(dir, "browser.lcov"), "TN:\n");
165
- const stats = await prepareV8Reports(dir);
166
- expect(stats.dumps).toBe(1);
167
- expect(stats.bytesOut).toBeLessThan(stats.bytesIn);
168
- const back = JSON.parse(await fs.readFile(path.join(dir, "coverage-1-2-0.json"), "utf8")) as typeof doc;
169
- expect(back.result.length).toBe(1);
170
- expect((back["source-map-cache"] as unknown as Cache)["file:///app/src/a.ts"][SOURCE_MAP_HASH_KEY]).toBeDefined();
171
- expect(await fs.readFile(path.join(dir, "browser.lcov"), "utf8")).toBe("TN:\n");
172
- // Shipped: a second pass leaves it alone, even when it is not JSON any more.
173
- await fs.writeFile(path.join(dir, "coverage-1-2-0.json"), "not json");
174
- expect((await prepareV8Reports(dir)).dumps).toBe(0);
175
- expect(await fs.readFile(path.join(dir, "coverage-1-2-0.json"), "utf8")).toBe("not json");
176
- // A later dump of the same script carries a stub for the map.
177
- await fs.writeFile(path.join(dir, "coverage-1-3-0.json"), JSON.stringify(doc));
178
- expect((await prepareV8Reports(dir)).dumps).toBe(1);
179
- const later = JSON.parse(await fs.readFile(path.join(dir, "coverage-1-3-0.json"), "utf8")) as typeof doc;
180
- expect(Object.keys((later["source-map-cache"] as unknown as Cache)["file:///app/src/a.ts"])).toEqual([SOURCE_MAP_REF_KEY]);
181
- // The next capture starts by removing what shipped; other files stay.
182
- await removeShippedV8Reports(dir);
183
- expect((await fs.readdir(dir)).sort()).toEqual(["browser.lcov"]);
184
- resetNodeCoverageMemory();
185
- });
186
- });
187
-
188
97
  /** A capture context over a real directory, no container. */
189
98
  function fakeCtx(dir: string, signal = new AbortController().signal): CoverageCaptureContext {
190
99
  return {
@@ -197,99 +106,9 @@ function fakeCtx(dir: string, signal = new AbortController().signal): CoverageCa
197
106
  async writeReport(name, content) {
198
107
  await fs.writeFile(path.join(dir, name), content);
199
108
  },
200
- async readContainerFile(p) {
201
- return fs.readFile(p).catch(() => null);
202
- },
203
109
  };
204
110
  }
205
111
 
206
- describe("source maps by reference", () => {
207
- test("lineLengthsOf matches Node's shape: \\n splits, \\r stays, last line counted", () => {
208
- expect(lineLengthsOf("ab\ncde\r\n\nf")).toEqual([2, 4, 0, 1]);
209
- expect(lineLengthsOf("")).toEqual([0]);
210
- expect(lineLengthsOf("x\n")).toEqual([1, 0]);
211
- });
212
- test("sourceMappingUrlOf takes the last comment", () => {
213
- expect(sourceMappingUrlOf("a\n//# sourceMappingURL=a.js.map\nb\n//# sourceMappingURL=b.js.map\n")).toBe("b.js.map");
214
- expect(sourceMappingUrlOf("//@ sourceMappingURL=old.map")).toBe("old.map");
215
- expect(sourceMappingUrlOf("no map here")).toBeNull();
216
- });
217
- test("resolves the map a comment names, drops sourcesContent, computes line lengths", async () => {
218
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covmap-"));
219
- await fs.mkdir(path.join(dir, "maps"));
220
- await fs.writeFile(path.join(dir, "cli.js"), "const a = 1;\nconsole.log(a);\n//# sourceMappingURL=maps/cli.map\n");
221
- await fs.writeFile(
222
- path.join(dir, "maps", "cli.map"),
223
- JSON.stringify({ version: 3, sources: ["../src/cli.ts"], mappings: "AAAA;AACA", names: [], sourcesContent: ["x"] }),
224
- );
225
- const entry = await resolveSourceMapFromContainer(fakeCtx(dir), `file://${path.join(dir, "cli.js")}`);
226
- expect(entry).not.toBeNull();
227
- expect(entry!.url).toBe(`file://${path.join(dir, "maps", "cli.map")}`);
228
- expect(entry!.data).toEqual({ version: 3, sources: ["../src/cli.ts"], mappings: "AAAA;AACA", names: [] });
229
- expect(entry!.lineLengths).toEqual([12, 15, 33, 0]);
230
- });
231
- test("with no comment, <script>.map next to the script is the map", async () => {
232
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covmap-"));
233
- await fs.writeFile(path.join(dir, "cli.js"), "x();\n");
234
- await fs.writeFile(path.join(dir, "cli.js.map"), JSON.stringify({ version: 3, sources: ["cli.ts"], mappings: "AAAA" }));
235
- const entry = await resolveSourceMapFromContainer(fakeCtx(dir), `file://${path.join(dir, "cli.js")}`);
236
- expect(entry!.url).toBe(`file://${path.join(dir, "cli.js.map")}`);
237
- expect(entry!.data).toEqual({ version: 3, sources: ["cli.ts"], mappings: "AAAA" });
238
- });
239
- test("resolves an inline data: URL map; no map, no file, or a foreign URL is null", async () => {
240
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covmap-"));
241
- const map = JSON.stringify({ version: 3, sources: ["a.ts"], mappings: "AAAA" });
242
- await fs.writeFile(
243
- path.join(dir, "inline.js"),
244
- `x();\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(map).toString("base64")}\n`,
245
- );
246
- const inline = await resolveSourceMapFromContainer(fakeCtx(dir), `file://${path.join(dir, "inline.js")}`);
247
- expect(inline!.url).toBeNull();
248
- expect(inline!.data).toEqual({ version: 3, sources: ["a.ts"], mappings: "AAAA" });
249
- await fs.writeFile(path.join(dir, "bare.js"), "x();\n");
250
- expect(await resolveSourceMapFromContainer(fakeCtx(dir), `file://${path.join(dir, "bare.js")}`)).toBeNull();
251
- await fs.writeFile(path.join(dir, "gone.js"), "x();\n//# sourceMappingURL=gone.js.map\n");
252
- expect(await resolveSourceMapFromContainer(fakeCtx(dir), `file://${path.join(dir, "gone.js")}`)).toBeNull();
253
- await fs.writeFile(path.join(dir, "http.js"), "x();\n//# sourceMappingURL=https://cdn.test/x.map\n");
254
- expect(await resolveSourceMapFromContainer(fakeCtx(dir), `file://${path.join(dir, "http.js")}`)).toBeNull();
255
- expect(await resolveSourceMapFromContainer(fakeCtx(dir), "node:fs")).toBeNull();
256
- const noRead = { ...fakeCtx(dir), readContainerFile: undefined };
257
- expect(await resolveSourceMapFromContainer(noRead, `file://${path.join(dir, "inline.js")}`)).toBeNull();
258
- });
259
- test("attachSourceMaps: a map once, a stub after, a missing map looked up once", async () => {
260
- const calls: string[] = [];
261
- const entry = { url: null, data: { version: 3, sources: ["a.ts"], mappings: "AAAA" }, lineLengths: [4] };
262
- const resolve = async (url: string) => {
263
- calls.push(url);
264
- return url.endsWith("/a.js") ? entry : null;
265
- };
266
- const shipped = new Map<string, string>();
267
- const missing = new Set<string>();
268
- const dump = () => ({
269
- result: [
270
- { url: "file:///app/a.js", ...ran },
271
- { url: "file:///app/b.js", ...ran },
272
- ],
273
- });
274
- const first = dump() as Record<string, unknown>;
275
- expect(await attachSourceMaps(first, resolve, shipped, missing)).toBe(1);
276
- const c1 = first["source-map-cache"] as Record<string, Record<string, unknown>>;
277
- expect(c1["file:///app/a.js"][SOURCE_MAP_HASH_KEY]).toMatch(/^sha256:/);
278
- expect(c1["file:///app/a.js"].lineLengths).toEqual([4]);
279
- expect("file:///app/b.js" in c1).toBe(false);
280
- expect(calls).toEqual(["file:///app/a.js", "file:///app/b.js"]);
281
- const second = dump() as Record<string, unknown>;
282
- expect(await attachSourceMaps(second, resolve, shipped, missing)).toBe(0);
283
- const c2 = second["source-map-cache"] as Record<string, Record<string, unknown>>;
284
- expect(c2["file:///app/a.js"]).toEqual({ [SOURCE_MAP_REF_KEY]: c1["file:///app/a.js"][SOURCE_MAP_HASH_KEY] });
285
- expect(calls.length).toBe(2); // b.js is not asked again
286
- // A dump that carries its own entry is left alone.
287
- const own = { ...dump(), "source-map-cache": { "file:///app/b.js": { data: { mappings: "" }, lineLengths: [1] } } } as Record<string, unknown>;
288
- expect(await attachSourceMaps(own, resolve, shipped, missing)).toBe(0);
289
- expect(calls.length).toBe(2);
290
- });
291
- });
292
-
293
112
  describe("browserCoverage", () => {
294
113
  test("writes an empty lcov when nothing ran for the service", async () => {
295
114
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
@@ -314,219 +133,227 @@ describe("browserCoverage", () => {
314
133
  });
315
134
 
316
135
  // The node hook against a real `node` (the box has one; skipped where it
317
- // does not). Every process armed with the hook must answer its own
318
- // socket with a fresh V8 report — including a server that sits behind a
319
- // wrapper process, the pnpm/tsx shape; stale sockets must be cleaned up;
320
- // the socket path must work across a bind mount — which is what a plain
321
- // directory path stands in for here.
136
+ // does not). The hook is the collector: every process armed with it
137
+ // must answer its own socket with a fresh dump — including a server
138
+ // behind a wrapper process, the pnpm/tsx shape — and write one at exit;
139
+ // a worker thread writes its own; a script record appears once per
140
+ // branch with the source's line lengths, every function, and the map
141
+ // found by comment, inline, or next to the script; stale sockets are
142
+ // removed; NODE_V8_COVERAGE is never set.
322
143
  const hasNode = await new Promise<boolean>((resolve) => {
323
144
  const p = spawn("node", ["--version"]);
324
145
  p.on("error", () => resolve(false));
325
146
  p.on("exit", (code) => resolve(code === 0));
326
147
  });
327
148
 
328
- const SERVER_JS = "setInterval(() => {}, 1000); process.stdout.write('up\\n');\n";
149
+ const SERVER_JS = "function tick() { return 1 }\nsetInterval(tick, 5); process.stdout.write('up\\n');\n";
329
150
  // A wrapper that spawns the real server as a child node process and
330
151
  // waits on it — what `pnpm exec`, `npm run` and the `tsx` binary do.
152
+ // It takes its child down with it on SIGTERM, so the test leaves no
153
+ // orphan holding the test runner's stdout pipe open.
331
154
  const WRAPPER_JS =
332
155
  "const { spawn } = require('node:child_process');\n" +
333
156
  "const c = spawn(process.execPath, [require('node:path').join(__dirname, 'server.js')], { stdio: 'inherit' });\n" +
334
- "c.on('exit', (code) => process.exit(code ?? 1));\n";
157
+ "c.on('exit', (code) => process.exit(code ?? 1));\n" +
158
+ "process.on('SIGTERM', () => { c.kill('SIGKILL'); process.exit(1); });\n";
159
+
160
+ interface Dump {
161
+ spectestVersion: number;
162
+ result: { url: string; spectestScript: string; functions: { functionName: string; ranges: { count: number }[] }[] }[];
163
+ }
164
+ interface ScriptRecord {
165
+ spectestScript: {
166
+ url: string;
167
+ lineLengths: number[] | null;
168
+ functions: [string, number, number][];
169
+ sourceMap: { url: string | null; data: Record<string, unknown> } | null;
170
+ };
171
+ }
335
172
 
336
173
  describe.if(hasNode)("nodeCoverage hook (real node)", () => {
174
+ const hookEnv = (dir: string) => ({ ...process.env, [NODE_COVERAGE_DIR_ENV]: dir, NODE_OPTIONS: `--require ${path.join(dir, "hook.cjs")}` });
337
175
  async function socketsIn(dir: string): Promise<string[]> {
338
176
  return (await fs.readdir(dir)).filter((n) => n.startsWith(NODE_COVERAGE_SOCKET_PREFIX)).sort();
339
177
  }
340
- async function reportsIn(dir: string): Promise<string[]> {
341
- return (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-") && n.endsWith(".json"));
178
+ async function dumpsIn(dir: string): Promise<string[]> {
179
+ return (await fs.readdir(dir)).filter(isNodeDumpName).sort();
180
+ }
181
+ async function readDump(dir: string, name: string): Promise<Dump> {
182
+ return JSON.parse(await fs.readFile(path.join(dir, name), "utf8")) as Dump;
183
+ }
184
+ async function records(dir: string): Promise<Map<string, ScriptRecord["spectestScript"]>> {
185
+ const out = new Map<string, ScriptRecord["spectestScript"]>();
186
+ const d = path.join(dir, NODE_COVERAGE_SCRIPTS_SUBDIR);
187
+ for (const n of (await fs.readdir(d).catch(() => [] as string[])).filter((n) => n.endsWith(".json"))) {
188
+ const r = JSON.parse(await fs.readFile(path.join(d, n), "utf8")) as ScriptRecord;
189
+ out.set(r.spectestScript.url, r.spectestScript);
190
+ }
191
+ return out;
192
+ }
193
+ async function scratch(): Promise<string> {
194
+ resetNodeCoverageMemory();
195
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
196
+ await fs.writeFile(path.join(dir, "hook.cjs"), NODE_COVERAGE_HOOK);
197
+ await fs.mkdir(path.join(dir, NODE_COVERAGE_SCRIPTS_SUBDIR));
198
+ await fs.mkdir(path.join(dir, NODE_COVERAGE_MARKERS_SUBDIR), { recursive: true });
199
+ return dir;
342
200
  }
343
201
  async function start(dir: string, entry: string, expectSockets: number): Promise<ChildProcess> {
344
- const hook = path.join(dir, "hook.cjs");
345
- await fs.writeFile(hook, NODE_COVERAGE_HOOK);
346
202
  await fs.writeFile(path.join(dir, "server.js"), SERVER_JS);
347
203
  await fs.writeFile(path.join(dir, "wrapper.js"), WRAPPER_JS);
348
- const child = spawn("node", [path.join(dir, entry)], {
349
- env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
350
- stdio: ["ignore", "pipe", "inherit"],
351
- });
204
+ const child = spawn("node", [path.join(dir, entry)], { env: hookEnv(dir), stdio: ["ignore", "pipe", "inherit"] });
352
205
  await new Promise<void>((resolve) => child.stdout!.once("data", () => resolve()));
353
206
  for (let i = 0; i < 100 && (await socketsIn(dir)).length < expectSockets; i++) {
354
207
  await new Promise((r) => setTimeout(r, 20));
355
208
  }
356
209
  return child;
357
210
  }
211
+ async function runOnce(dir: string, entry: string): Promise<number | null> {
212
+ const p = spawn("node", [path.join(dir, entry)], { env: hookEnv(dir), stdio: "ignore" });
213
+ return new Promise<number | null>((r) => p.on("exit", r));
214
+ }
358
215
 
359
- test("capture asks the live server; a short-lived process exits and unlinks its socket", async () => {
360
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
216
+ test("capture asks the live server: a dump of what ran, a script record once, and only the app's code", async () => {
217
+ const dir = await scratch();
361
218
  const server = await start(dir, "server.js", 1);
362
219
  try {
363
- expect(await reportsIn(dir)).toEqual([]);
220
+ expect(await dumpsIn(dir)).toEqual([]);
364
221
  await node().capture!(fakeCtx(dir));
365
- const reports = await reportsIn(dir);
366
- expect(reports.length).toBe(1);
367
- const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as {
368
- result: { url: string }[];
369
- };
370
- expect(doc.result.some((s) => s.url.endsWith("/server.js"))).toBe(true);
222
+ const [name] = await dumpsIn(dir);
223
+ const dump = await readDump(dir, name!);
224
+ expect(dump.spectestVersion).toBe(1);
225
+ expect(dump.result.map((s) => s.url)).toEqual([`file://${path.join(dir, "server.js")}`]);
226
+ const fns = dump.result[0]!.functions;
227
+ expect(fns.every((f) => f.ranges.some((r) => r.count > 0))).toBe(true);
228
+ const rec = (await records(dir)).get(`file://${path.join(dir, "server.js")}`)!;
229
+ expect(rec.lineLengths).toEqual([28, 51, 0]);
230
+ expect(rec.functions.map((f) => f[0])).toEqual(["", "tick"]);
231
+ expect(rec.sourceMap).toBeNull();
232
+ expect(dump.result[0]!.spectestScript).toBeDefined();
233
+ const markers = await fs.readdir(path.join(dir, NODE_COVERAGE_MARKERS_SUBDIR));
234
+ expect(markers).toEqual([dump.result[0]!.spectestScript]);
371
235
 
372
- const short = spawn("node", ["-e", "1"], {
373
- env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${path.join(dir, "hook.cjs")}` },
374
- stdio: "ignore",
375
- });
376
- const code = await new Promise<number | null>((resolve) => short.on("exit", resolve));
377
- expect(code).toBe(0);
378
- expect((await socketsIn(dir)).length).toBe(1); // its socket is gone with it
379
- expect((await reportsIn(dir)).length).toBe(2); // its exit-time report is there
380
-
381
- // The next capture ships only what is new: the first dump goes,
382
- // the short process's exit dump and the server's fresh dump stay.
236
+ // The next capture: the first dump is gone, a new one describes
237
+ // only what ran since (tick), and the record is not written again.
238
+ await new Promise((r) => setTimeout(r, 30));
383
239
  await node().capture!(fakeCtx(dir));
384
- const next = await reportsIn(dir);
385
- expect(next.length).toBe(2);
386
- expect(next).not.toContain(reports[0]!);
240
+ const names = await dumpsIn(dir);
241
+ expect(names.length).toBe(1);
242
+ expect(names[0]).not.toBe(name);
243
+ const second = await readDump(dir, names[0]!);
244
+ expect(second.result[0]!.functions.map((f) => f.functionName)).toEqual(["tick"]);
245
+ expect((await fs.readdir(path.join(dir, NODE_COVERAGE_SCRIPTS_SUBDIR))).length).toBe(0);
387
246
  } finally {
388
247
  server.kill("SIGKILL");
389
248
  }
390
249
  });
391
250
 
392
251
  test("a server behind a wrapper process is dumped too (pnpm exec / tsx shape)", async () => {
393
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
252
+ const dir = await scratch();
394
253
  const wrapper = await start(dir, "wrapper.js", 2);
395
254
  try {
396
255
  expect((await socketsIn(dir)).length).toBe(2);
397
256
  await node().capture!(fakeCtx(dir));
398
- const reports = await reportsIn(dir);
399
- expect(reports.length).toBe(2);
400
257
  const urls = new Set<string>();
401
- for (const r of reports) {
402
- const doc = JSON.parse(await fs.readFile(path.join(dir, r), "utf8")) as { result: { url: string }[] };
403
- for (const s of doc.result) urls.add(s.url);
404
- }
258
+ for (const n of await dumpsIn(dir)) for (const s of (await readDump(dir, n)).result) urls.add(s.url);
405
259
  expect([...urls].some((u) => u.endsWith("/server.js"))).toBe(true);
406
260
  expect([...urls].some((u) => u.endsWith("/wrapper.js"))).toBe(true);
407
261
  } finally {
408
- wrapper.kill("SIGKILL");
409
- // The wrapper's child is orphaned by SIGKILL; find and kill it too.
410
- const pids = (await socketsIn(dir)).map((n) => Number(n.slice(NODE_COVERAGE_SOCKET_PREFIX.length)));
411
- for (const pid of pids) {
412
- try {
413
- process.kill(pid, "SIGKILL");
414
- } catch {}
415
- }
262
+ wrapper.kill("SIGTERM");
263
+ await new Promise<void>((r) => wrapper.on("exit", () => r()));
416
264
  }
417
265
  });
418
266
 
419
- test("short-lived CLI processes: exit dumps are the coverage, no live process needed", async () => {
420
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
421
- const hook = path.join(dir, "coverage-hook.cjs");
422
- await fs.writeFile(hook, NODE_COVERAGE_HOOK);
423
- await fs.writeFile(path.join(dir, "cli.js"), "process.stdout.write('v1');\n");
267
+ test("short-lived CLI processes write at exit; the map next to the script is recorded once, no NODE_V8_COVERAGE", async () => {
268
+ const dir = await scratch();
269
+ await fs.writeFile(path.join(dir, "cli.js"), "function used() { return 1 }\nfunction unused() { return 2 }\nused();\n");
270
+ await fs.writeFile(path.join(dir, "cli.js.map"), JSON.stringify({ version: 3, sources: ["cli.ts"], mappings: "AAAA;AACA", sourcesContent: ["src"] }));
424
271
  // Nothing has run yet: an empty report, not a failure.
425
272
  await node().capture!(fakeCtx(dir));
426
- expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
427
- // Two CLI runs, each its own process, each dumping at exit.
428
- for (let i = 0; i < 2; i++) {
429
- const cli = spawn("node", [path.join(dir, "cli.js")], {
430
- env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
431
- stdio: "ignore",
432
- });
433
- expect(await new Promise<number | null>((r) => cli.on("exit", r))).toBe(0);
434
- }
273
+ expect(await fs.readdir(dir).then((n) => n.filter((x) => x === NODE_COVERAGE_EMPTY_REPORT))).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
274
+ for (let i = 0; i < 2; i++) expect(await runOnce(dir, "cli.js")).toBe(0);
435
275
  expect((await socketsIn(dir)).length).toBe(0);
276
+ expect(await fs.readdir(dir).then((n) => n.filter((x) => x.startsWith("coverage-") && x !== NODE_COVERAGE_EMPTY_REPORT))).toEqual([]); // no NODE_V8_COVERAGE dumps
277
+ await node().capture!(fakeCtx(dir));
278
+ const names = await dumpsIn(dir);
279
+ expect(names.length).toBe(2);
280
+ for (const n of names) {
281
+ const d = await readDump(dir, n);
282
+ expect(d.result.map((s) => s.url)).toEqual([`file://${path.join(dir, "cli.js")}`]);
283
+ expect(d.result[0]!.functions.map((f) => f.functionName).sort()).toEqual(["", "used"]);
284
+ }
285
+ const recs = await records(dir);
286
+ expect(recs.size).toBe(1);
287
+ const rec = recs.get(`file://${path.join(dir, "cli.js")}`)!;
288
+ expect(rec.functions.map((f) => f[0])).toEqual(["", "used", "unused"]);
289
+ expect(rec.lineLengths).toEqual([28, 30, 7, 0]);
290
+ expect(rec.sourceMap!.url).toBe(`file://${path.join(dir, "cli.js.map")}`);
291
+ expect(rec.sourceMap!.data).toEqual({ version: 3, sources: ["cli.ts"], mappings: "AAAA;AACA" });
292
+ // The next capture removes what shipped; a third run is a new dump
293
+ // and still no new record.
294
+ expect(await runOnce(dir, "cli.js")).toBe(0);
436
295
  await node().capture!(fakeCtx(dir));
437
- const reports = (await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT);
438
- expect(reports.length).toBe(2);
439
- const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as { result: { url: string }[] };
440
- expect(doc.result.map((x) => x.url)).toEqual([`file://${path.join(dir, "cli.js")}`]); // compacted
296
+ expect((await dumpsIn(dir)).length).toBe(1);
297
+ expect((await records(dir)).size).toBe(0);
441
298
  });
442
299
 
443
- test("a worker thread does not bind the socket; the main thread's dump is the app's", async () => {
444
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
445
- const hook = path.join(dir, "coverage-hook.cjs");
446
- await fs.writeFile(hook, NODE_COVERAGE_HOOK);
447
- await fs.writeFile(
448
- path.join(dir, "server.js"),
449
- "function appWork() { return 1 } appWork();\n" +
450
- "new (require('node:worker_threads').Worker)('setInterval(() => {}, 1000)', { eval: true });\n" +
451
- SERVER_JS,
452
- );
453
- const child = spawn("node", [path.join(dir, "server.js")], {
454
- env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
455
- stdio: ["ignore", "pipe", "inherit"],
300
+ test("maps: the file a comment names, and an inline data: map; a comment Node would have cached costs nothing", async () => {
301
+ const dir = await scratch();
302
+ await fs.mkdir(path.join(dir, "maps"));
303
+ await fs.writeFile(path.join(dir, "a.js"), "x = 1;\n//# sourceMappingURL=maps/a.map\n");
304
+ await fs.writeFile(path.join(dir, "maps", "a.map"), JSON.stringify({ version: 3, sources: ["../a.ts"], mappings: "AAAA" }));
305
+ const inline = JSON.stringify({ version: 3, sources: ["b.ts"], mappings: "AACA" });
306
+ await fs.writeFile(path.join(dir, "b.js"), `y = 2;\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(inline).toString("base64")}\n`);
307
+ await fs.writeFile(path.join(dir, "c.js"), "z = 3;\n//# sourceMappingURL=https://cdn.test/c.map\n");
308
+ await fs.writeFile(path.join(dir, "main.js"), "require('./a.js'); require('./b.js'); require('./c.js');\n");
309
+ expect(await runOnce(dir, "main.js")).toBe(0);
310
+ await node().capture!(fakeCtx(dir));
311
+ const recs = await records(dir);
312
+ expect(recs.get(`file://${path.join(dir, "a.js")}`)!.sourceMap).toEqual({
313
+ url: `file://${path.join(dir, "maps", "a.map")}`,
314
+ data: { version: 3, sources: ["../a.ts"], mappings: "AAAA" },
456
315
  });
457
- await new Promise<void>((resolve) => child.stdout!.once("data", () => resolve()));
458
- try {
459
- await new Promise((r) => setTimeout(r, 300)); // let the worker thread start
460
- expect((await socketsIn(dir)).length).toBe(1);
461
- await node().capture!(fakeCtx(dir));
462
- const reports = (await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT);
463
- expect(reports.length).toBe(1);
464
- const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as { result: { url: string }[] };
465
- expect(doc.result.some((s) => s.url.endsWith("/server.js"))).toBe(true);
466
- } finally {
467
- child.kill("SIGKILL");
468
- }
316
+ expect(recs.get(`file://${path.join(dir, "b.js")}`)!.sourceMap).toEqual({ url: null, data: { version: 3, sources: ["b.ts"], mappings: "AACA" } });
317
+ expect(recs.get(`file://${path.join(dir, "c.js")}`)!.sourceMap).toBeNull();
318
+ expect(recs.get(`file://${path.join(dir, "main.js")}`)!.sourceMap).toBeNull();
469
319
  });
470
320
 
471
- test("a stale socket left by a killed process is removed at capture", async () => {
472
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
473
- const first = await start(dir, "server.js", 1);
474
- first.kill("SIGKILL");
475
- await new Promise<void>((resolve) => first.on("exit", () => resolve()));
476
- expect((await socketsIn(dir)).length).toBe(1); // stale
321
+ test("a worker thread writes its own dump at exit; only the main thread binds the socket", async () => {
322
+ const dir = await scratch();
323
+ await fs.writeFile(
324
+ path.join(dir, "threads.js"),
325
+ "const { Worker, isMainThread } = require('node:worker_threads');\n" +
326
+ "function inWorker() { return 1 }\n" +
327
+ "if (isMainThread) { new Worker(__filename); } else { inWorker(); }\n",
328
+ );
329
+ expect(await runOnce(dir, "threads.js")).toBe(0);
330
+ expect((await socketsIn(dir)).length).toBe(0);
477
331
  await node().capture!(fakeCtx(dir));
478
- expect((await socketsIn(dir)).length).toBe(0); // cleaned up
479
- const second = await start(dir, "server.js", 1);
480
- try {
481
- await node().capture!(fakeCtx(dir));
482
- expect((await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT).length).toBe(1);
483
- } finally {
484
- second.kill("SIGKILL");
485
- }
332
+ const names = await dumpsIn(dir);
333
+ expect(names.length).toBe(2);
334
+ const byThread = new Map<string, Dump>();
335
+ for (const n of names) byThread.set(n.split("-")[2]!, await readDump(dir, n));
336
+ const worker = [...byThread.entries()].find(([t]) => t !== "0")![1];
337
+ expect(worker.result[0]!.functions.some((f) => f.functionName === "inWorker")).toBe(true);
338
+ const main = byThread.get("0")!;
339
+ expect(main.result[0]!.functions.some((f) => f.functionName === "inWorker")).toBe(false);
340
+ expect((await records(dir)).size).toBe(1); // one script, one record, whichever thread came first
486
341
  });
487
342
 
488
- test("a CLI built with an external map and no comment gets its map from the file next to the script, once per branch", async () => {
489
- // Node caches a map it finds through a sourceMappingURL comment into
490
- // every dump under NODE_V8_COVERAGE, flag or no flag (checked: the
491
- // entry then carries Node's resolved `sources` and `sourceRoot`).
492
- // Without the comment the dump has no map and the adapter reads
493
- // `cli.js.map` from the container.
494
- resetNodeCoverageMemory();
495
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
496
- const hook = path.join(dir, "coverage-hook.cjs");
497
- await fs.writeFile(hook, NODE_COVERAGE_HOOK);
498
- await fs.writeFile(path.join(dir, "cli.js"), "function used() { return 1 }\nused();\n");
499
- await fs.writeFile(path.join(dir, "cli.js.map"), JSON.stringify({ version: 3, sources: ["cli.ts"], mappings: "AAAA;AACA", sourcesContent: ["src"] }));
500
- const url = `file://${path.join(dir, "cli.js")}`;
501
- for (let i = 0; i < 2; i++) {
502
- const cli = spawn("node", [path.join(dir, "cli.js")], {
503
- env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
504
- stdio: "ignore",
505
- });
506
- expect(await new Promise<number | null>((r) => cli.on("exit", r))).toBe(0);
507
- await node().capture!(fakeCtx(dir));
508
- const [name] = (await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT);
509
- const doc = JSON.parse(await fs.readFile(path.join(dir, name!), "utf8")) as {
510
- result: { url: string }[];
511
- "source-map-cache": Record<string, Record<string, unknown>>;
512
- };
513
- expect(doc.result.map((x) => x.url)).toEqual([url]);
514
- const entry = doc["source-map-cache"][url]!;
515
- if (i === 0) {
516
- expect(entry[SOURCE_MAP_HASH_KEY]).toMatch(/^sha256:/);
517
- expect(entry.data).toEqual({ version: 3, sources: ["cli.ts"], mappings: "AAAA;AACA" });
518
- expect(entry.lineLengths).toEqual([28, 7, 0]);
519
- } else {
520
- expect(Object.keys(entry)).toEqual([SOURCE_MAP_REF_KEY]);
521
- }
522
- }
523
- resetNodeCoverageMemory();
343
+ test("a stale socket left by a killed process is removed at capture", async () => {
344
+ const dir = await scratch();
345
+ const server = await start(dir, "server.js", 1);
346
+ server.kill("SIGKILL");
347
+ await new Promise<void>((r) => server.on("exit", () => r()));
348
+ expect((await socketsIn(dir)).length).toBe(1); // SIGKILL: no exit handler, socket left behind
349
+ await node().capture!(fakeCtx(dir));
350
+ expect((await socketsIn(dir)).length).toBe(0);
524
351
  });
525
352
 
526
353
  test("no process and no dump: an empty report is written, once", async () => {
527
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
354
+ const dir = await scratch();
528
355
  await node().capture!(fakeCtx(dir));
529
356
  await node().capture!(fakeCtx(dir));
530
- expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
357
+ expect((await fs.readdir(dir)).filter((n) => n.endsWith(".json"))).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
531
358
  });
532
359
  });