@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.
- package/dist/coverage.d.ts +87 -137
- package/dist/coverage.js +321 -367
- package/dist/daemon.js +11 -24
- package/dist/harness/coverage.d.ts +6 -1
- package/dist/harness/coverage.js +11 -3
- package/package.json +1 -1
- package/src/coverage.test.ts +163 -336
- package/src/coverage.ts +331 -399
- package/src/daemon.ts +11 -19
- package/src/harness/coverage.ts +12 -3
package/src/coverage.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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?.
|
|
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).
|
|
318
|
-
// socket with a fresh
|
|
319
|
-
// wrapper process, the pnpm/tsx shape
|
|
320
|
-
//
|
|
321
|
-
//
|
|
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 = "
|
|
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
|
|
341
|
-
return (await fs.readdir(dir)).filter(
|
|
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
|
|
360
|
-
const dir = await
|
|
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
|
|
220
|
+
expect(await dumpsIn(dir)).toEqual([]);
|
|
364
221
|
await node().capture!(fakeCtx(dir));
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
expect(
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
385
|
-
expect(
|
|
386
|
-
expect(
|
|
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
|
|
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
|
|
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("
|
|
409
|
-
|
|
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
|
|
420
|
-
const dir = await
|
|
421
|
-
|
|
422
|
-
await fs.writeFile(
|
|
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
|
|
427
|
-
|
|
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
|
-
|
|
438
|
-
expect(
|
|
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
|
|
444
|
-
const dir = await
|
|
445
|
-
|
|
446
|
-
await fs.writeFile(
|
|
447
|
-
await fs.writeFile(
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
);
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
472
|
-
const dir = await
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
|
354
|
+
const dir = await scratch();
|
|
528
355
|
await node().capture!(fakeCtx(dir));
|
|
529
356
|
await node().capture!(fakeCtx(dir));
|
|
530
|
-
expect(await
|
|
357
|
+
expect((await fs.readdir(dir)).filter((n) => n.endsWith(".json"))).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
|
|
531
358
|
});
|
|
532
359
|
});
|