@specific.dev/spectest 0.81.1 → 0.83.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
- resolveSourceMapsFromContainer,
18
- sourceMapProbeCommand,
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,217 +94,21 @@ 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 {
191
100
  service: "api",
192
101
  reportDir: dir,
193
102
  signal,
194
- // The probe runs through a real shell; the "container" paths are
195
- // this directory's own.
196
- async exec(command: string) {
197
- return new Promise((resolve, reject) => {
198
- const p = spawn("sh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"] });
199
- let stdout = "";
200
- let stderr = "";
201
- p.stdout!.on("data", (b: Buffer) => (stdout += b));
202
- p.stderr!.on("data", (b: Buffer) => (stderr += b));
203
- p.on("exit", (code) => (code === 0 ? resolve({ stdout, stderr }) : reject(new Error(stderr || `exit ${code}`))));
204
- });
103
+ async exec() {
104
+ throw new Error("no container");
205
105
  },
206
106
  async writeReport(name, content) {
207
107
  await fs.writeFile(path.join(dir, name), content);
208
108
  },
209
- async readContainerFile(p) {
210
- return fs.readFile(p).catch(() => null);
211
- },
212
109
  };
213
110
  }
214
111
 
215
- describe("source maps by reference", () => {
216
- test("lineLengthsOf matches Node's shape: \\n splits, \\r stays, last line counted", () => {
217
- expect(lineLengthsOf("ab\ncde\r\n\nf")).toEqual([2, 4, 0, 1]);
218
- expect(lineLengthsOf("")).toEqual([0]);
219
- expect(lineLengthsOf("x\n")).toEqual([1, 0]);
220
- });
221
- test("the probe: one exec, one line per script — the last comment's ref, the .map next to it, or none", async () => {
222
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covprobe-"));
223
- await fs.writeFile(path.join(dir, "a.js"), "x\n//# sourceMappingURL=old.map\ny\n//# sourceMappingURL=maps/a.map\n");
224
- await fs.writeFile(path.join(dir, "b.js"), "x();\n");
225
- await fs.writeFile(path.join(dir, "b.js.map"), "{}");
226
- await fs.writeFile(path.join(dir, "it's.js"), "x();\n");
227
- const r = await fakeCtx(dir).exec(
228
- sourceMapProbeCommand([path.join(dir, "a.js"), path.join(dir, "b.js"), path.join(dir, "it's.js"), path.join(dir, "gone.js")]),
229
- );
230
- expect(r.stdout).toBe(
231
- `${path.join(dir, "a.js")}\tmaps/a.map\n` +
232
- `${path.join(dir, "b.js")}\t${path.join(dir, "b.js.map")}\n` +
233
- `${path.join(dir, "it's.js")}\t-\n` +
234
- `${path.join(dir, "gone.js")}\t!\n`,
235
- );
236
- });
237
- test("resolves the map a comment names, drops sourcesContent, computes line lengths", async () => {
238
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covmap-"));
239
- await fs.mkdir(path.join(dir, "maps"));
240
- await fs.writeFile(path.join(dir, "cli.js"), "const a = 1;\nconsole.log(a);\n//# sourceMappingURL=maps/cli.map\n");
241
- await fs.writeFile(
242
- path.join(dir, "maps", "cli.map"),
243
- JSON.stringify({ version: 3, sources: ["../src/cli.ts"], mappings: "AAAA;AACA", names: [], sourcesContent: ["x"] }),
244
- );
245
- const url = `file://${path.join(dir, "cli.js")}`;
246
- const out = await resolveSourceMapsFromContainer(fakeCtx(dir), [url, "node:fs"]);
247
- expect([...out.keys()]).toEqual([url]);
248
- const entry = out.get(url)!;
249
- expect(entry.url).toBe(`file://${path.join(dir, "maps", "cli.map")}`);
250
- expect(entry.data).toEqual({ version: 3, sources: ["../src/cli.ts"], mappings: "AAAA;AACA", names: [] });
251
- expect(entry.lineLengths).toEqual([12, 15, 33, 0]);
252
- });
253
- test("with no comment, <script>.map next to the script is the map; inline data: maps decode; the rest is absent", async () => {
254
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covmap-"));
255
- await fs.writeFile(path.join(dir, "cli.js"), "x();\n");
256
- await fs.writeFile(path.join(dir, "cli.js.map"), JSON.stringify({ version: 3, sources: ["cli.ts"], mappings: "AAAA" }));
257
- const map = JSON.stringify({ version: 3, sources: ["a.ts"], mappings: "AAAA" });
258
- await fs.writeFile(
259
- path.join(dir, "inline.js"),
260
- `x();\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(map).toString("base64")}\n`,
261
- );
262
- await fs.writeFile(path.join(dir, "bare.js"), "x();\n");
263
- await fs.writeFile(path.join(dir, "gone.js"), "x();\n//# sourceMappingURL=gone.js.map\n");
264
- await fs.writeFile(path.join(dir, "http.js"), "x();\n//# sourceMappingURL=https://cdn.test/x.map\n");
265
- await fs.writeFile(path.join(dir, "notjson.js"), "x();\n");
266
- await fs.writeFile(path.join(dir, "notjson.js.map"), "nope");
267
- const u = (n: string) => `file://${path.join(dir, n)}`;
268
- const out = await resolveSourceMapsFromContainer(fakeCtx(dir), [
269
- u("cli.js"), u("inline.js"), u("bare.js"), u("gone.js"), u("http.js"), u("notjson.js"), u("missing.js"),
270
- ]);
271
- expect([...out.keys()].sort()).toEqual([u("cli.js"), u("inline.js")].sort());
272
- expect(out.get(u("cli.js"))!.url).toBe(`file://${path.join(dir, "cli.js.map")}`);
273
- expect(out.get(u("cli.js"))!.data).toEqual({ version: 3, sources: ["cli.ts"], mappings: "AAAA" });
274
- expect(out.get(u("inline.js"))!.url).toBeNull();
275
- expect(out.get(u("inline.js"))!.data).toEqual({ version: 3, sources: ["a.ts"], mappings: "AAAA" });
276
- const noRead = { ...fakeCtx(dir), readContainerFile: undefined };
277
- expect((await resolveSourceMapsFromContainer(noRead, [u("cli.js")])).size).toBe(0);
278
- });
279
- test("attachSourceMaps: one batched lookup, a map once, a stub after, a missing map looked up once", async () => {
280
- const calls: string[][] = [];
281
- const entry = { url: null, data: { version: 3, sources: ["a.ts"], mappings: "AAAA" }, lineLengths: [4] };
282
- const resolve = async (urls: string[]) => {
283
- calls.push(urls);
284
- return new Map(urls.filter((u) => u.endsWith("/a.js")).map((u) => [u, entry]));
285
- };
286
- const shipped = new Map<string, string>();
287
- const missing = new Set<string>();
288
- const dump = () => ({
289
- result: [
290
- { url: "file:///app/a.js", ...ran },
291
- { url: "file:///app/b.js", ...ran },
292
- ],
293
- });
294
- const first = dump() as Record<string, unknown>;
295
- expect(await attachSourceMaps(first, resolve, shipped, missing)).toBe(1);
296
- const c1 = first["source-map-cache"] as Record<string, Record<string, unknown>>;
297
- expect(c1["file:///app/a.js"][SOURCE_MAP_HASH_KEY]).toMatch(/^sha256:/);
298
- expect(c1["file:///app/a.js"].lineLengths).toEqual([4]);
299
- expect("file:///app/b.js" in c1).toBe(false);
300
- expect(calls).toEqual([["file:///app/a.js", "file:///app/b.js"]]);
301
- const second = dump() as Record<string, unknown>;
302
- expect(await attachSourceMaps(second, resolve, shipped, missing)).toBe(0);
303
- const c2 = second["source-map-cache"] as Record<string, Record<string, unknown>>;
304
- expect(c2["file:///app/a.js"]).toEqual({ [SOURCE_MAP_REF_KEY]: c1["file:///app/a.js"][SOURCE_MAP_HASH_KEY] });
305
- expect(calls.length).toBe(1); // b.js is not asked again
306
- // A dump that carries its own entry is left alone.
307
- const own = { ...dump(), "source-map-cache": { "file:///app/b.js": { data: { mappings: "" }, lineLengths: [1] } } } as Record<string, unknown>;
308
- expect(await attachSourceMaps(own, resolve, shipped, missing)).toBe(0);
309
- expect(calls.length).toBe(1);
310
- });
311
- });
312
-
313
112
  describe("browserCoverage", () => {
314
113
  test("writes an empty lcov when nothing ran for the service", async () => {
315
114
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
@@ -334,219 +133,237 @@ describe("browserCoverage", () => {
334
133
  });
335
134
 
336
135
  // The node hook against a real `node` (the box has one; skipped where it
337
- // does not). Every process armed with the hook must answer its own
338
- // socket with a fresh V8 report — including a server that sits behind a
339
- // wrapper process, the pnpm/tsx shape; stale sockets must be cleaned up;
340
- // the socket path must work across a bind mount — which is what a plain
341
- // 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.
342
143
  const hasNode = await new Promise<boolean>((resolve) => {
343
144
  const p = spawn("node", ["--version"]);
344
145
  p.on("error", () => resolve(false));
345
146
  p.on("exit", (code) => resolve(code === 0));
346
147
  });
347
148
 
348
- 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";
349
150
  // A wrapper that spawns the real server as a child node process and
350
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.
351
154
  const WRAPPER_JS =
352
155
  "const { spawn } = require('node:child_process');\n" +
353
156
  "const c = spawn(process.execPath, [require('node:path').join(__dirname, 'server.js')], { stdio: 'inherit' });\n" +
354
- "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: 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
+ }
355
172
 
356
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")}` });
357
175
  async function socketsIn(dir: string): Promise<string[]> {
358
176
  return (await fs.readdir(dir)).filter((n) => n.startsWith(NODE_COVERAGE_SOCKET_PREFIX)).sort();
359
177
  }
360
- async function reportsIn(dir: string): Promise<string[]> {
361
- 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
+ /** The names of a dump's ran functions, joined to the record on the extent. */
194
+ function ranNames(dump: Dump, rec: ScriptRecord["spectestScript"]): string[] {
195
+ const byExtent = new Map(rec.functions.map(([name, start, end]) => [`${start}-${end}`, name]));
196
+ return dump.result[0]!.functions.map((f) => byExtent.get(`${f[0]}-${f[0]! + f[1]!}`) ?? "?");
197
+ }
198
+ async function scratch(): Promise<string> {
199
+ resetNodeCoverageMemory();
200
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
201
+ await fs.writeFile(path.join(dir, "hook.cjs"), NODE_COVERAGE_HOOK);
202
+ await fs.mkdir(path.join(dir, NODE_COVERAGE_SCRIPTS_SUBDIR));
203
+ await fs.mkdir(path.join(dir, NODE_COVERAGE_MARKERS_SUBDIR), { recursive: true });
204
+ return dir;
362
205
  }
363
206
  async function start(dir: string, entry: string, expectSockets: number): Promise<ChildProcess> {
364
- const hook = path.join(dir, "hook.cjs");
365
- await fs.writeFile(hook, NODE_COVERAGE_HOOK);
366
207
  await fs.writeFile(path.join(dir, "server.js"), SERVER_JS);
367
208
  await fs.writeFile(path.join(dir, "wrapper.js"), WRAPPER_JS);
368
- const child = spawn("node", [path.join(dir, entry)], {
369
- env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
370
- stdio: ["ignore", "pipe", "inherit"],
371
- });
209
+ const child = spawn("node", [path.join(dir, entry)], { env: hookEnv(dir), stdio: ["ignore", "pipe", "inherit"] });
372
210
  await new Promise<void>((resolve) => child.stdout!.once("data", () => resolve()));
373
211
  for (let i = 0; i < 100 && (await socketsIn(dir)).length < expectSockets; i++) {
374
212
  await new Promise((r) => setTimeout(r, 20));
375
213
  }
376
214
  return child;
377
215
  }
216
+ async function runOnce(dir: string, entry: string): Promise<number | null> {
217
+ const p = spawn("node", [path.join(dir, entry)], { env: hookEnv(dir), stdio: "ignore" });
218
+ return new Promise<number | null>((r) => p.on("exit", r));
219
+ }
378
220
 
379
- test("capture asks the live server; a short-lived process exits and unlinks its socket", async () => {
380
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
221
+ test("capture asks the live server: a dump of what ran, a script record once, and only the app's code", async () => {
222
+ const dir = await scratch();
381
223
  const server = await start(dir, "server.js", 1);
382
224
  try {
383
- expect(await reportsIn(dir)).toEqual([]);
225
+ expect(await dumpsIn(dir)).toEqual([]);
384
226
  await node().capture!(fakeCtx(dir));
385
- const reports = await reportsIn(dir);
386
- expect(reports.length).toBe(1);
387
- const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as {
388
- result: { url: string }[];
389
- };
390
- expect(doc.result.some((s) => s.url.endsWith("/server.js"))).toBe(true);
391
-
392
- const short = spawn("node", ["-e", "1"], {
393
- env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${path.join(dir, "hook.cjs")}` },
394
- stdio: "ignore",
395
- });
396
- const code = await new Promise<number | null>((resolve) => short.on("exit", resolve));
397
- expect(code).toBe(0);
398
- expect((await socketsIn(dir)).length).toBe(1); // its socket is gone with it
399
- expect((await reportsIn(dir)).length).toBe(2); // its exit-time report is there
227
+ const [name] = await dumpsIn(dir);
228
+ const dump = await readDump(dir, name!);
229
+ expect(dump.spectestVersion).toBe(2);
230
+ expect(dump.result.map((s) => s.url)).toEqual([`file://${path.join(dir, "server.js")}`]);
231
+ const fns = dump.result[0]!.functions;
232
+ // Triples: the extent, then blocks relative to it; some count > 0.
233
+ expect(fns.every((f) => f.length >= 3 && f.length % 3 === 0)).toBe(true);
234
+ expect(fns.every((f) => f.some((_, i) => i % 3 === 2 && f[i]! > 0))).toBe(true);
235
+ const rec = (await records(dir)).get(`file://${path.join(dir, "server.js")}`)!;
236
+ expect(ranNames(dump, rec).sort()).toEqual(["", "tick"]);
237
+ expect(rec.lineLengths).toEqual([28, 51, 0]);
238
+ expect(rec.functions.map((f) => f[0])).toEqual(["", "tick"]);
239
+ expect(rec.sourceMap).toBeNull();
240
+ expect(dump.result[0]!.spectestScript).toBeDefined();
241
+ const markers = await fs.readdir(path.join(dir, NODE_COVERAGE_MARKERS_SUBDIR));
242
+ expect(markers).toEqual([dump.result[0]!.spectestScript]);
400
243
 
401
- // The next capture ships only what is new: the first dump goes,
402
- // the short process's exit dump and the server's fresh dump stay.
244
+ // The next capture: the first dump is gone, a new one describes
245
+ // only what ran since (tick), and the record is not written again.
246
+ await new Promise((r) => setTimeout(r, 30));
403
247
  await node().capture!(fakeCtx(dir));
404
- const next = await reportsIn(dir);
405
- expect(next.length).toBe(2);
406
- expect(next).not.toContain(reports[0]!);
248
+ const names = await dumpsIn(dir);
249
+ expect(names.length).toBe(1);
250
+ expect(names[0]).not.toBe(name);
251
+ const second = await readDump(dir, names[0]!);
252
+ expect(ranNames(second, rec)).toEqual(["tick"]);
253
+ expect((await fs.readdir(path.join(dir, NODE_COVERAGE_SCRIPTS_SUBDIR))).length).toBe(0);
407
254
  } finally {
408
255
  server.kill("SIGKILL");
409
256
  }
410
257
  });
411
258
 
412
259
  test("a server behind a wrapper process is dumped too (pnpm exec / tsx shape)", async () => {
413
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
260
+ const dir = await scratch();
414
261
  const wrapper = await start(dir, "wrapper.js", 2);
415
262
  try {
416
263
  expect((await socketsIn(dir)).length).toBe(2);
417
264
  await node().capture!(fakeCtx(dir));
418
- const reports = await reportsIn(dir);
419
- expect(reports.length).toBe(2);
420
265
  const urls = new Set<string>();
421
- for (const r of reports) {
422
- const doc = JSON.parse(await fs.readFile(path.join(dir, r), "utf8")) as { result: { url: string }[] };
423
- for (const s of doc.result) urls.add(s.url);
424
- }
266
+ for (const n of await dumpsIn(dir)) for (const s of (await readDump(dir, n)).result) urls.add(s.url);
425
267
  expect([...urls].some((u) => u.endsWith("/server.js"))).toBe(true);
426
268
  expect([...urls].some((u) => u.endsWith("/wrapper.js"))).toBe(true);
427
269
  } finally {
428
- wrapper.kill("SIGKILL");
429
- // The wrapper's child is orphaned by SIGKILL; find and kill it too.
430
- const pids = (await socketsIn(dir)).map((n) => Number(n.slice(NODE_COVERAGE_SOCKET_PREFIX.length)));
431
- for (const pid of pids) {
432
- try {
433
- process.kill(pid, "SIGKILL");
434
- } catch {}
435
- }
270
+ wrapper.kill("SIGTERM");
271
+ await new Promise<void>((r) => wrapper.on("exit", () => r()));
436
272
  }
437
273
  });
438
274
 
439
- test("short-lived CLI processes: exit dumps are the coverage, no live process needed", async () => {
440
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
441
- const hook = path.join(dir, "coverage-hook.cjs");
442
- await fs.writeFile(hook, NODE_COVERAGE_HOOK);
443
- await fs.writeFile(path.join(dir, "cli.js"), "process.stdout.write('v1');\n");
275
+ test("short-lived CLI processes write at exit; the map next to the script is recorded once, no NODE_V8_COVERAGE", async () => {
276
+ const dir = await scratch();
277
+ await fs.writeFile(path.join(dir, "cli.js"), "function used() { return 1 }\nfunction unused() { return 2 }\nused();\n");
278
+ await fs.writeFile(path.join(dir, "cli.js.map"), JSON.stringify({ version: 3, sources: ["cli.ts"], mappings: "AAAA;AACA", sourcesContent: ["src"] }));
444
279
  // Nothing has run yet: an empty report, not a failure.
445
280
  await node().capture!(fakeCtx(dir));
446
- expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
447
- // Two CLI runs, each its own process, each dumping at exit.
448
- for (let i = 0; i < 2; i++) {
449
- const cli = spawn("node", [path.join(dir, "cli.js")], {
450
- env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
451
- stdio: "ignore",
452
- });
453
- expect(await new Promise<number | null>((r) => cli.on("exit", r))).toBe(0);
454
- }
281
+ expect(await fs.readdir(dir).then((n) => n.filter((x) => x === NODE_COVERAGE_EMPTY_REPORT))).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
282
+ for (let i = 0; i < 2; i++) expect(await runOnce(dir, "cli.js")).toBe(0);
455
283
  expect((await socketsIn(dir)).length).toBe(0);
284
+ expect(await fs.readdir(dir).then((n) => n.filter((x) => x.startsWith("coverage-") && x !== NODE_COVERAGE_EMPTY_REPORT))).toEqual([]); // no NODE_V8_COVERAGE dumps
285
+ await node().capture!(fakeCtx(dir));
286
+ const names = await dumpsIn(dir);
287
+ expect(names.length).toBe(2);
288
+ const recs = await records(dir);
289
+ expect(recs.size).toBe(1);
290
+ const rec = recs.get(`file://${path.join(dir, "cli.js")}`)!;
291
+ for (const n of names) {
292
+ const d = await readDump(dir, n);
293
+ expect(d.result.map((s) => s.url)).toEqual([`file://${path.join(dir, "cli.js")}`]);
294
+ expect(ranNames(d, rec).sort()).toEqual(["", "used"]);
295
+ }
296
+ expect(rec.functions.map((f) => f[0])).toEqual(["", "used", "unused"]);
297
+ expect(rec.lineLengths).toEqual([28, 30, 7, 0]);
298
+ expect(rec.sourceMap!.url).toBe(`file://${path.join(dir, "cli.js.map")}`);
299
+ expect(rec.sourceMap!.data).toEqual({ version: 3, sources: ["cli.ts"], mappings: "AAAA;AACA" });
300
+ // The next capture removes what shipped; a third run is a new dump
301
+ // and still no new record.
302
+ expect(await runOnce(dir, "cli.js")).toBe(0);
456
303
  await node().capture!(fakeCtx(dir));
457
- const reports = (await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT);
458
- expect(reports.length).toBe(2);
459
- const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as { result: { url: string }[] };
460
- expect(doc.result.map((x) => x.url)).toEqual([`file://${path.join(dir, "cli.js")}`]); // compacted
304
+ expect((await dumpsIn(dir)).length).toBe(1);
305
+ expect((await records(dir)).size).toBe(0);
461
306
  });
462
307
 
463
- test("a worker thread does not bind the socket; the main thread's dump is the app's", async () => {
464
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
465
- const hook = path.join(dir, "coverage-hook.cjs");
466
- await fs.writeFile(hook, NODE_COVERAGE_HOOK);
467
- await fs.writeFile(
468
- path.join(dir, "server.js"),
469
- "function appWork() { return 1 } appWork();\n" +
470
- "new (require('node:worker_threads').Worker)('setInterval(() => {}, 1000)', { eval: true });\n" +
471
- SERVER_JS,
472
- );
473
- const child = spawn("node", [path.join(dir, "server.js")], {
474
- env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
475
- stdio: ["ignore", "pipe", "inherit"],
308
+ test("maps: the file a comment names, and an inline data: map; a comment Node would have cached costs nothing", async () => {
309
+ const dir = await scratch();
310
+ await fs.mkdir(path.join(dir, "maps"));
311
+ await fs.writeFile(path.join(dir, "a.js"), "x = 1;\n//# sourceMappingURL=maps/a.map\n");
312
+ await fs.writeFile(path.join(dir, "maps", "a.map"), JSON.stringify({ version: 3, sources: ["../a.ts"], mappings: "AAAA" }));
313
+ const inline = JSON.stringify({ version: 3, sources: ["b.ts"], mappings: "AACA" });
314
+ await fs.writeFile(path.join(dir, "b.js"), `y = 2;\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(inline).toString("base64")}\n`);
315
+ await fs.writeFile(path.join(dir, "c.js"), "z = 3;\n//# sourceMappingURL=https://cdn.test/c.map\n");
316
+ await fs.writeFile(path.join(dir, "main.js"), "require('./a.js'); require('./b.js'); require('./c.js');\n");
317
+ expect(await runOnce(dir, "main.js")).toBe(0);
318
+ await node().capture!(fakeCtx(dir));
319
+ const recs = await records(dir);
320
+ expect(recs.get(`file://${path.join(dir, "a.js")}`)!.sourceMap).toEqual({
321
+ url: `file://${path.join(dir, "maps", "a.map")}`,
322
+ data: { version: 3, sources: ["../a.ts"], mappings: "AAAA" },
476
323
  });
477
- await new Promise<void>((resolve) => child.stdout!.once("data", () => resolve()));
478
- try {
479
- await new Promise((r) => setTimeout(r, 300)); // let the worker thread start
480
- expect((await socketsIn(dir)).length).toBe(1);
481
- await node().capture!(fakeCtx(dir));
482
- const reports = (await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT);
483
- expect(reports.length).toBe(1);
484
- const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as { result: { url: string }[] };
485
- expect(doc.result.some((s) => s.url.endsWith("/server.js"))).toBe(true);
486
- } finally {
487
- child.kill("SIGKILL");
488
- }
324
+ expect(recs.get(`file://${path.join(dir, "b.js")}`)!.sourceMap).toEqual({ url: null, data: { version: 3, sources: ["b.ts"], mappings: "AACA" } });
325
+ expect(recs.get(`file://${path.join(dir, "c.js")}`)!.sourceMap).toBeNull();
326
+ expect(recs.get(`file://${path.join(dir, "main.js")}`)!.sourceMap).toBeNull();
489
327
  });
490
328
 
491
- test("a stale socket left by a killed process is removed at capture", async () => {
492
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
493
- const first = await start(dir, "server.js", 1);
494
- first.kill("SIGKILL");
495
- await new Promise<void>((resolve) => first.on("exit", () => resolve()));
496
- expect((await socketsIn(dir)).length).toBe(1); // stale
329
+ test("a worker thread writes its own dump at exit; only the main thread binds the socket", async () => {
330
+ const dir = await scratch();
331
+ await fs.writeFile(
332
+ path.join(dir, "threads.js"),
333
+ "const { Worker, isMainThread } = require('node:worker_threads');\n" +
334
+ "function inWorker() { return 1 }\n" +
335
+ "if (isMainThread) { new Worker(__filename); } else { inWorker(); }\n",
336
+ );
337
+ expect(await runOnce(dir, "threads.js")).toBe(0);
338
+ expect((await socketsIn(dir)).length).toBe(0);
497
339
  await node().capture!(fakeCtx(dir));
498
- expect((await socketsIn(dir)).length).toBe(0); // cleaned up
499
- const second = await start(dir, "server.js", 1);
500
- try {
501
- await node().capture!(fakeCtx(dir));
502
- expect((await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT).length).toBe(1);
503
- } finally {
504
- second.kill("SIGKILL");
505
- }
340
+ const names = await dumpsIn(dir);
341
+ expect(names.length).toBe(2);
342
+ const byThread = new Map<string, Dump>();
343
+ for (const n of names) byThread.set(n.split("-")[2]!, await readDump(dir, n));
344
+ const recs = await records(dir);
345
+ expect(recs.size).toBe(1); // one script, one record, whichever thread came first
346
+ const rec = recs.get(`file://${path.join(dir, "threads.js")}`)!;
347
+ const worker = [...byThread.entries()].find(([t]) => t !== "0")![1];
348
+ expect(ranNames(worker, rec)).toContain("inWorker");
349
+ const main = byThread.get("0")!;
350
+ expect(ranNames(main, rec)).not.toContain("inWorker");
506
351
  });
507
352
 
508
- 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 () => {
509
- // Node caches a map it finds through a sourceMappingURL comment into
510
- // every dump under NODE_V8_COVERAGE, flag or no flag (checked: the
511
- // entry then carries Node's resolved `sources` and `sourceRoot`).
512
- // Without the comment the dump has no map and the adapter reads
513
- // `cli.js.map` from the container.
514
- resetNodeCoverageMemory();
515
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
516
- const hook = path.join(dir, "coverage-hook.cjs");
517
- await fs.writeFile(hook, NODE_COVERAGE_HOOK);
518
- await fs.writeFile(path.join(dir, "cli.js"), "function used() { return 1 }\nused();\n");
519
- await fs.writeFile(path.join(dir, "cli.js.map"), JSON.stringify({ version: 3, sources: ["cli.ts"], mappings: "AAAA;AACA", sourcesContent: ["src"] }));
520
- const url = `file://${path.join(dir, "cli.js")}`;
521
- for (let i = 0; i < 2; i++) {
522
- const cli = spawn("node", [path.join(dir, "cli.js")], {
523
- env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
524
- stdio: "ignore",
525
- });
526
- expect(await new Promise<number | null>((r) => cli.on("exit", r))).toBe(0);
527
- await node().capture!(fakeCtx(dir));
528
- const [name] = (await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT);
529
- const doc = JSON.parse(await fs.readFile(path.join(dir, name!), "utf8")) as {
530
- result: { url: string }[];
531
- "source-map-cache": Record<string, Record<string, unknown>>;
532
- };
533
- expect(doc.result.map((x) => x.url)).toEqual([url]);
534
- const entry = doc["source-map-cache"][url]!;
535
- if (i === 0) {
536
- expect(entry[SOURCE_MAP_HASH_KEY]).toMatch(/^sha256:/);
537
- expect(entry.data).toEqual({ version: 3, sources: ["cli.ts"], mappings: "AAAA;AACA" });
538
- expect(entry.lineLengths).toEqual([28, 7, 0]);
539
- } else {
540
- expect(Object.keys(entry)).toEqual([SOURCE_MAP_REF_KEY]);
541
- }
542
- }
543
- resetNodeCoverageMemory();
353
+ test("a stale socket left by a killed process is removed at capture", async () => {
354
+ const dir = await scratch();
355
+ const server = await start(dir, "server.js", 1);
356
+ server.kill("SIGKILL");
357
+ await new Promise<void>((r) => server.on("exit", () => r()));
358
+ expect((await socketsIn(dir)).length).toBe(1); // SIGKILL: no exit handler, socket left behind
359
+ await node().capture!(fakeCtx(dir));
360
+ expect((await socketsIn(dir)).length).toBe(0);
544
361
  });
545
362
 
546
363
  test("no process and no dump: an empty report is written, once", async () => {
547
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
364
+ const dir = await scratch();
548
365
  await node().capture!(fakeCtx(dir));
549
366
  await node().capture!(fakeCtx(dir));
550
- expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
367
+ expect((await fs.readdir(dir)).filter((n) => n.endsWith(".json"))).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
551
368
  });
552
369
  });