@specific.dev/spectest 0.79.2 → 0.81.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,27 @@ 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_C8_BIN,
8
- NODE_COVERAGE_DUMPS_SUBDIR,
9
7
  NODE_COVERAGE_HOOK,
10
8
  NODE_COVERAGE_EMPTY_REPORT,
11
- NODE_COVERAGE_LCOV,
12
- NODE_COVERAGE_TOOLS_CONTAINER_DIR,
13
9
  NODE_COVERAGE_HOOK_PATH,
14
10
  NODE_COVERAGE_SOCKET_PREFIX,
15
- absoluteLcovPaths,
11
+ SOURCE_MAP_HASH_KEY,
12
+ SOURCE_MAP_REF_KEY,
16
13
  applyCoverageAdapters,
17
14
  appendEnvFlag,
15
+ attachSourceMaps,
16
+ lineLengthsOf,
17
+ resolveSourceMapFromContainer,
18
+ sourceMappingUrlOf,
18
19
  browser,
19
20
  command,
20
21
  coverageReportsMode,
21
22
  compactV8Document,
22
- compactV8Reports,
23
- COVERAGE_CONTAINER_DIR,
24
23
  node,
25
- nodeCoverageConvertCommand,
26
- nodeCoverageToolsAvailable,
27
- nodeCoverageToolsDir,
24
+ prepareV8Reports,
25
+ removeShippedV8Reports,
26
+ resetNodeCoverageMemory,
27
+ scriptExecuted,
28
28
  validateCoverage,
29
29
  type CoverageCaptureContext,
30
30
  } from "./coverage.js";
@@ -99,58 +99,89 @@ describe("applyCoverageAdapters", () => {
99
99
  });
100
100
  });
101
101
 
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
+
102
105
  describe("compactV8Document", () => {
103
106
  const doc = {
104
107
  result: [
105
- { scriptId: "1", url: "file:///app/src/a.ts", functions: [] },
106
- { scriptId: "2", url: "file:///app/node_modules/x/index.js", functions: [] },
107
- { scriptId: "3", url: "node:fs", functions: [] },
108
- { scriptId: "5", url: "file:///root/.cache/node/corepack/v1/pnpm/10.0.0/dist/pnpm.cjs", functions: [] },
109
- { scriptId: "4", url: "", functions: [] },
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 },
110
114
  ],
111
115
  "source-map-cache": {
112
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 },
113
118
  "file:///app/node_modules/x/index.js": { lineLengths: [1], data: { mappings: "AAAA" }, url: null },
114
119
  },
115
120
  };
116
- test("keeps app scripts and their maps, drops the rest and sourcesContent", () => {
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", () => {
117
129
  const out = compactV8Document(doc) as typeof doc;
118
130
  expect(out.result.map((s) => s.url)).toEqual(["file:///app/src/a.ts"]);
119
- expect(Object.keys(out["source-map-cache"])).toEqual(["file:///app/src/a.ts"]);
120
- expect(out["source-map-cache"]["file:///app/src/a.ts"].data).toEqual({ mappings: "AAAA", sources: ["a.ts"] });
121
- });
122
- test("keepSourcesContent keeps the sources for the in-container conversion", () => {
123
- const out = compactV8Document(doc, { keepSourcesContent: true }) as typeof doc;
124
- expect(out["source-map-cache"]["file:///app/src/a.ts"].data).toEqual({
125
- mappings: "AAAA",
126
- sources: ["a.ts"],
127
- sourcesContent: ["x"],
128
- });
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();
129
136
  });
130
- test("absoluteLcovPaths makes c8's root-relative SF records container paths", () => {
131
- expect(absoluteLcovPaths("TN:\nSF:app/src/a.ts\nDA:1,1\nend_of_record\nSF:/abs.js\nend_of_record\n")).toBe(
132
- "TN:\nSF:/app/src/a.ts\nDA:1,1\nend_of_record\nSF:/abs.js\nend_of_record\n",
133
- );
134
- expect(absoluteLcovPaths("")).toBe("TN:\n");
135
- expect(absoluteLcovPaths(" \n")).toBe("TN:\n");
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);
136
154
  });
137
155
  test("a dump with no app script keeps an empty result and no cache", () => {
138
156
  const out = compactV8Document({ result: [doc.result[2]], "source-map-cache": doc["source-map-cache"] });
139
157
  expect(out.result).toEqual([]);
140
158
  expect("source-map-cache" in out).toBe(false);
141
159
  });
142
- test("compactV8Reports rewrites coverage-*.json once and leaves other files alone", async () => {
160
+ test("prepareV8Reports compacts coverage-*.json once, ships maps once, and the next capture removes them", async () => {
161
+ resetNodeCoverageMemory();
143
162
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
144
163
  await fs.writeFile(path.join(dir, "coverage-1-2-0.json"), JSON.stringify(doc));
145
164
  await fs.writeFile(path.join(dir, "browser.lcov"), "TN:\n");
146
- await compactV8Reports(dir);
147
- const back = JSON.parse(await fs.readFile(path.join(dir, "coverage-1-2-0.json"), "utf8")) as { result: unknown[] };
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;
148
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();
149
171
  expect(await fs.readFile(path.join(dir, "browser.lcov"), "utf8")).toBe("TN:\n");
150
- // Idempotent and cheap: a second pass is a no-op on the same file.
172
+ // Shipped: a second pass leaves it alone, even when it is not JSON any more.
151
173
  await fs.writeFile(path.join(dir, "coverage-1-2-0.json"), "not json");
152
- await compactV8Reports(dir);
174
+ expect((await prepareV8Reports(dir)).dumps).toBe(0);
153
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();
154
185
  });
155
186
  });
156
187
 
@@ -166,9 +197,99 @@ function fakeCtx(dir: string, signal = new AbortController().signal): CoverageCa
166
197
  async writeReport(name, content) {
167
198
  await fs.writeFile(path.join(dir, name), content);
168
199
  },
200
+ async readContainerFile(p) {
201
+ return fs.readFile(p).catch(() => null);
202
+ },
169
203
  };
170
204
  }
171
205
 
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
+
172
293
  describe("browserCoverage", () => {
173
294
  test("writes an empty lcov when nothing ran for the service", async () => {
174
295
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
@@ -257,8 +378,12 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
257
378
  expect((await socketsIn(dir)).length).toBe(1); // its socket is gone with it
258
379
  expect((await reportsIn(dir)).length).toBe(2); // its exit-time report is there
259
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.
260
383
  await node().capture!(fakeCtx(dir));
261
- expect((await reportsIn(dir)).length).toBe(3);
384
+ const next = await reportsIn(dir);
385
+ expect(next.length).toBe(2);
386
+ expect(next).not.toContain(reports[0]!);
262
387
  } finally {
263
388
  server.kill("SIGKILL");
264
389
  }
@@ -360,106 +485,48 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
360
485
  }
361
486
  });
362
487
 
363
- test("no process and no dump: an empty report is written, once", async () => {
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();
364
495
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
365
- await node().capture!(fakeCtx(dir));
366
- await node().capture!(fakeCtx(dir));
367
- expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
368
- });
369
- });
370
-
371
- // The conversion to lcov against a real `node` and a real `c8` tree.
372
- // Golden ships the tree at /opt/spectest/coverage-tools; on a dev box
373
- // point SPECTEST_C8_TEST_DIR at one (`bun install --linker=hoisted` of
374
- // `c8` in a scratch dir). Skipped where either is missing. The exec here
375
- // is the container's `sh -c` with the container paths rewritten to this
376
- // directory: the command is exactly what a container runs.
377
- const c8TestDir = process.env.SPECTEST_C8_TEST_DIR || "/opt/spectest/coverage-tools";
378
- const hasC8 = await fs
379
- .access(path.join(c8TestDir, NODE_COVERAGE_C8_BIN))
380
- .then(() => true)
381
- .catch(() => false);
382
-
383
- describe.if(hasNode && hasC8)("nodeCoverage → lcov (real node + c8)", () => {
384
- function c8Ctx(dir: string): CoverageCaptureContext {
385
- return {
386
- ...fakeCtx(dir),
387
- async exec(command: string) {
388
- const local = command
389
- .replaceAll(NODE_COVERAGE_TOOLS_CONTAINER_DIR, c8TestDir)
390
- .replaceAll(COVERAGE_CONTAINER_DIR, dir);
391
- return new Promise((resolve, reject) => {
392
- const p = spawn("sh", ["-c", local], { stdio: ["ignore", "pipe", "pipe"] });
393
- let out = "";
394
- p.stdout!.on("data", (b: Buffer) => (out += b));
395
- p.stderr!.on("data", (b: Buffer) => (out += b));
396
- p.on("exit", (code) => (code === 0 ? resolve({ stdout: out, stderr: "" }) : reject(new Error(out))));
397
- });
398
- },
399
- };
400
- }
401
- async function withTools<T>(f: () => Promise<T>): Promise<T> {
402
- const prior = process.env.SPECTEST_COVERAGE_TOOLS_DIR;
403
- process.env.SPECTEST_COVERAGE_TOOLS_DIR = c8TestDir;
404
- try {
405
- return await f();
406
- } finally {
407
- if (prior === undefined) delete process.env.SPECTEST_COVERAGE_TOOLS_DIR;
408
- else process.env.SPECTEST_COVERAGE_TOOLS_DIR = prior;
409
- }
410
- }
411
-
412
- test("the tools dir is found through the override, and the command is the container's", async () => {
413
- await withTools(async () => {
414
- expect(nodeCoverageToolsDir()).toBe(c8TestDir);
415
- expect(await nodeCoverageToolsAvailable()).toBe(true);
416
- });
417
- expect(nodeCoverageConvertCommand()).toBe(
418
- `cd / && env -u NODE_V8_COVERAGE -u NODE_OPTIONS node ${NODE_COVERAGE_TOOLS_CONTAINER_DIR}/${NODE_COVERAGE_C8_BIN} report --temp-directory ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_DUMPS_SUBDIR} --reporter=lcovonly --reports-dir ${COVERAGE_CONTAINER_DIR}/.c8`,
419
- );
420
- });
421
-
422
- test("dumps are converted to node.lcov, the delta of this capture; the raw dumps never ship", async () => {
423
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covc8-"));
424
496
  const hook = path.join(dir, "coverage-hook.cjs");
425
497
  await fs.writeFile(hook, NODE_COVERAGE_HOOK);
426
- await fs.writeFile(path.join(dir, "cli.js"), "function used() { return 1 }\nfunction unused() { return 2 }\nused();\n");
427
- await withTools(async () => {
428
- // Nothing ran yet: an empty lcov, no exec.
429
- await node().capture!(c8Ctx(dir));
430
- expect(await fs.readFile(path.join(dir, NODE_COVERAGE_LCOV), "utf8")).toBe("TN:\n");
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++) {
431
502
  const cli = spawn("node", [path.join(dir, "cli.js")], {
432
503
  env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
433
504
  stdio: "ignore",
434
505
  });
435
506
  expect(await new Promise<number | null>((r) => cli.on("exit", r))).toBe(0);
436
- await node().capture!(c8Ctx(dir));
437
- const lcov = await fs.readFile(path.join(dir, NODE_COVERAGE_LCOV), "utf8");
438
- expect(lcov).toContain(`SF:${path.join(dir, "cli.js")}\n`);
439
- expect(lcov).toMatch(/FNDA:1,used/);
440
- expect(lcov).toMatch(/FNDA:0,unused/);
441
- // The hook itself is not the app's code.
442
- expect(lcov).not.toContain("coverage-hook.cjs");
443
- // Raw dumps moved under the hidden .v8/ dir: the visible reports
444
- // are the lcov alone.
445
- const visible = (await fs.readdir(dir)).filter((n) => !n.startsWith(".") && n.startsWith("coverage-") && n.endsWith(".json"));
446
- expect(visible).toEqual([]);
447
- const dumps = (await fs.readdir(path.join(dir, NODE_COVERAGE_DUMPS_SUBDIR))).filter((n) => n.endsWith(".json"));
448
- expect(dumps.length).toBe(1);
449
- // Nothing new since: the next capture is an empty report, not the
450
- // previous one again — the lcov is a delta.
451
- await node().capture!(c8Ctx(dir));
452
- expect(await fs.readFile(path.join(dir, NODE_COVERAGE_LCOV), "utf8")).toBe("TN:\n");
453
- // A new run is a new delta on its own: counts of 1, not 2.
454
- const again = spawn("node", [path.join(dir, "cli.js")], {
455
- env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
456
- stdio: "ignore",
457
- });
458
- await new Promise((r) => again.on("exit", r));
459
- await node().capture!(c8Ctx(dir));
460
- const lcov2 = await fs.readFile(path.join(dir, NODE_COVERAGE_LCOV), "utf8");
461
- expect(lcov2).toMatch(/FNDA:1,used/);
462
- expect((await fs.readdir(path.join(dir, NODE_COVERAGE_DUMPS_SUBDIR))).filter((n) => n.endsWith(".json")).length).toBe(1);
463
- });
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();
524
+ });
525
+
526
+ 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-"));
528
+ await node().capture!(fakeCtx(dir));
529
+ await node().capture!(fakeCtx(dir));
530
+ expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
464
531
  });
465
532
  });