@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.
- package/dist/coverage.d.ts +59 -69
- package/dist/coverage.js +110 -171
- package/dist/daemon.js +5 -13
- package/package.json +1 -1
- package/src/coverage.test.ts +69 -134
- package/src/coverage.ts +135 -177
- package/src/daemon.ts +4 -15
package/src/coverage.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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",
|
|
106
|
-
{ scriptId: "2", url: "file:///app/node_modules/x/index.js",
|
|
107
|
-
{ scriptId: "3", url: "node:fs",
|
|
108
|
-
{ scriptId: "5", url: "file:///root/.cache/node/corepack/v1/pnpm/10.0.0/dist/pnpm.cjs",
|
|
109
|
-
{ scriptId: "
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
expect(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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("
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
);
|
|
134
|
-
expect(
|
|
135
|
-
|
|
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("
|
|
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
|
|
147
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
});
|