@specific.dev/spectest 0.79.2 → 0.80.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,23 @@ 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,
18
15
  browser,
19
16
  command,
20
17
  coverageReportsMode,
21
18
  compactV8Document,
22
- compactV8Reports,
23
- COVERAGE_CONTAINER_DIR,
24
19
  node,
25
- nodeCoverageConvertCommand,
26
- nodeCoverageToolsAvailable,
27
- nodeCoverageToolsDir,
20
+ prepareV8Reports,
21
+ removeShippedV8Reports,
22
+ resetNodeCoverageMemory,
23
+ scriptExecuted,
28
24
  validateCoverage,
29
25
  type CoverageCaptureContext,
30
26
  } from "./coverage.js";
@@ -99,58 +95,89 @@ describe("applyCoverageAdapters", () => {
99
95
  });
100
96
  });
101
97
 
98
+ const ran = { functions: [{ functionName: "", ranges: [{ startOffset: 0, endOffset: 10, count: 1 }] }] };
99
+ const idle = { functions: [{ functionName: "", ranges: [{ startOffset: 0, endOffset: 10, count: 0 }] }] };
100
+
102
101
  describe("compactV8Document", () => {
103
102
  const doc = {
104
103
  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: [] },
104
+ { scriptId: "1", url: "file:///app/src/a.ts", ...ran },
105
+ { scriptId: "2", url: "file:///app/node_modules/x/index.js", ...ran },
106
+ { scriptId: "3", url: "node:fs", ...ran },
107
+ { scriptId: "5", url: "file:///root/.cache/node/corepack/v1/pnpm/10.0.0/dist/pnpm.cjs", ...ran },
108
+ { scriptId: "6", url: "file:///app/src/idle.ts", ...idle },
109
+ { scriptId: "4", url: "", ...ran },
110
110
  ],
111
111
  "source-map-cache": {
112
112
  "file:///app/src/a.ts": { lineLengths: [1], data: { mappings: "AAAA", sources: ["a.ts"], sourcesContent: ["x"] }, url: null },
113
+ "file:///app/src/idle.ts": { lineLengths: [1], data: { mappings: "AAAA", sources: ["idle.ts"] }, url: null },
113
114
  "file:///app/node_modules/x/index.js": { lineLengths: [1], data: { mappings: "AAAA" }, url: null },
114
115
  },
115
116
  };
116
- test("keeps app scripts and their maps, drops the rest and sourcesContent", () => {
117
+ type Cache = Record<string, Record<string, unknown>>;
118
+
119
+ test("scriptExecuted: any range count above zero", () => {
120
+ expect(scriptExecuted(ran)).toBe(true);
121
+ expect(scriptExecuted(idle)).toBe(false);
122
+ expect(scriptExecuted({})).toBe(false);
123
+ });
124
+ test("keeps app scripts that ran and their maps, drops the rest and sourcesContent", () => {
117
125
  const out = compactV8Document(doc) as typeof doc;
118
126
  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
- });
127
+ const cache = out["source-map-cache"] as unknown as Cache;
128
+ expect(Object.keys(cache)).toEqual(["file:///app/src/a.ts"]);
129
+ expect(cache["file:///app/src/a.ts"].data).toEqual({ mappings: "AAAA", sources: ["a.ts"] });
130
+ expect(cache["file:///app/src/a.ts"][SOURCE_MAP_HASH_KEY]).toMatch(/^sha256:[0-9a-f]{64}$/);
131
+ expect(cache["file:///app/src/a.ts"][SOURCE_MAP_REF_KEY]).toBeUndefined();
129
132
  });
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");
133
+ test("a map shipped earlier on the branch becomes a stub that refers to its hash", () => {
134
+ const shipped = new Map<string, string>();
135
+ const first = compactV8Document(doc, shipped) as typeof doc;
136
+ const hash = (first["source-map-cache"] as unknown as Cache)["file:///app/src/a.ts"][SOURCE_MAP_HASH_KEY];
137
+ expect(shipped.get("file:///app/src/a.ts")).toBe(hash as string);
138
+ const second = compactV8Document(doc, shipped) as typeof doc;
139
+ expect((second["source-map-cache"] as unknown as Cache)["file:///app/src/a.ts"]).toEqual({ [SOURCE_MAP_REF_KEY]: hash });
140
+ // The hash is over the compacted entry, so the same map with its
141
+ // sources dropped is the same map; a changed map ships again.
142
+ const changed = {
143
+ ...doc,
144
+ "source-map-cache": { "file:///app/src/a.ts": { lineLengths: [2], data: { mappings: "AAAC", sources: ["a.ts"] }, url: null } },
145
+ };
146
+ const third = compactV8Document(changed, shipped) as typeof doc;
147
+ const entry = (third["source-map-cache"] as unknown as Cache)["file:///app/src/a.ts"];
148
+ expect(entry[SOURCE_MAP_REF_KEY]).toBeUndefined();
149
+ expect(entry[SOURCE_MAP_HASH_KEY]).not.toBe(hash);
136
150
  });
137
151
  test("a dump with no app script keeps an empty result and no cache", () => {
138
152
  const out = compactV8Document({ result: [doc.result[2]], "source-map-cache": doc["source-map-cache"] });
139
153
  expect(out.result).toEqual([]);
140
154
  expect("source-map-cache" in out).toBe(false);
141
155
  });
142
- test("compactV8Reports rewrites coverage-*.json once and leaves other files alone", async () => {
156
+ test("prepareV8Reports compacts coverage-*.json once, ships maps once, and the next capture removes them", async () => {
157
+ resetNodeCoverageMemory();
143
158
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
144
159
  await fs.writeFile(path.join(dir, "coverage-1-2-0.json"), JSON.stringify(doc));
145
160
  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[] };
161
+ const stats = await prepareV8Reports(dir);
162
+ expect(stats.dumps).toBe(1);
163
+ expect(stats.bytesOut).toBeLessThan(stats.bytesIn);
164
+ const back = JSON.parse(await fs.readFile(path.join(dir, "coverage-1-2-0.json"), "utf8")) as typeof doc;
148
165
  expect(back.result.length).toBe(1);
166
+ expect((back["source-map-cache"] as unknown as Cache)["file:///app/src/a.ts"][SOURCE_MAP_HASH_KEY]).toBeDefined();
149
167
  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.
168
+ // Shipped: a second pass leaves it alone, even when it is not JSON any more.
151
169
  await fs.writeFile(path.join(dir, "coverage-1-2-0.json"), "not json");
152
- await compactV8Reports(dir);
170
+ expect((await prepareV8Reports(dir)).dumps).toBe(0);
153
171
  expect(await fs.readFile(path.join(dir, "coverage-1-2-0.json"), "utf8")).toBe("not json");
172
+ // A later dump of the same script carries a stub for the map.
173
+ await fs.writeFile(path.join(dir, "coverage-1-3-0.json"), JSON.stringify(doc));
174
+ expect((await prepareV8Reports(dir)).dumps).toBe(1);
175
+ const later = JSON.parse(await fs.readFile(path.join(dir, "coverage-1-3-0.json"), "utf8")) as typeof doc;
176
+ expect(Object.keys((later["source-map-cache"] as unknown as Cache)["file:///app/src/a.ts"])).toEqual([SOURCE_MAP_REF_KEY]);
177
+ // The next capture starts by removing what shipped; other files stay.
178
+ await removeShippedV8Reports(dir);
179
+ expect((await fs.readdir(dir)).sort()).toEqual(["browser.lcov"]);
180
+ resetNodeCoverageMemory();
154
181
  });
155
182
  });
156
183
 
@@ -257,8 +284,12 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
257
284
  expect((await socketsIn(dir)).length).toBe(1); // its socket is gone with it
258
285
  expect((await reportsIn(dir)).length).toBe(2); // its exit-time report is there
259
286
 
287
+ // The next capture ships only what is new: the first dump goes,
288
+ // the short process's exit dump and the server's fresh dump stay.
260
289
  await node().capture!(fakeCtx(dir));
261
- expect((await reportsIn(dir)).length).toBe(3);
290
+ const next = await reportsIn(dir);
291
+ expect(next.length).toBe(2);
292
+ expect(next).not.toContain(reports[0]!);
262
293
  } finally {
263
294
  server.kill("SIGKILL");
264
295
  }
@@ -367,99 +398,3 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
367
398
  expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
368
399
  });
369
400
  });
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
- const hook = path.join(dir, "coverage-hook.cjs");
425
- 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");
431
- const cli = spawn("node", [path.join(dir, "cli.js")], {
432
- env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
433
- stdio: "ignore",
434
- });
435
- 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
- });
464
- });
465
- });