@specific.dev/spectest 0.81.1 → 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 -153
- package/dist/coverage.js +319 -419
- 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 +165 -358
- package/src/coverage.ts +330 -449
- 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
|
-
resolveSourceMapsFromContainer,
|
|
18
|
-
sourceMapProbeCommand,
|
|
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,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
|
-
|
|
195
|
-
|
|
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,227 @@ 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).
|
|
338
|
-
// socket with a fresh
|
|
339
|
-
// wrapper process, the pnpm/tsx shape
|
|
340
|
-
//
|
|
341
|
-
//
|
|
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 = "
|
|
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: { 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
|
+
}
|
|
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
|
|
361
|
-
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;
|
|
362
200
|
}
|
|
363
201
|
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
202
|
await fs.writeFile(path.join(dir, "server.js"), SERVER_JS);
|
|
367
203
|
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
|
-
});
|
|
204
|
+
const child = spawn("node", [path.join(dir, entry)], { env: hookEnv(dir), stdio: ["ignore", "pipe", "inherit"] });
|
|
372
205
|
await new Promise<void>((resolve) => child.stdout!.once("data", () => resolve()));
|
|
373
206
|
for (let i = 0; i < 100 && (await socketsIn(dir)).length < expectSockets; i++) {
|
|
374
207
|
await new Promise((r) => setTimeout(r, 20));
|
|
375
208
|
}
|
|
376
209
|
return child;
|
|
377
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
|
+
}
|
|
378
215
|
|
|
379
|
-
test("capture asks the live server
|
|
380
|
-
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();
|
|
381
218
|
const server = await start(dir, "server.js", 1);
|
|
382
219
|
try {
|
|
383
|
-
expect(await
|
|
220
|
+
expect(await dumpsIn(dir)).toEqual([]);
|
|
384
221
|
await node().capture!(fakeCtx(dir));
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
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]);
|
|
391
235
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
400
|
-
|
|
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.
|
|
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));
|
|
403
239
|
await node().capture!(fakeCtx(dir));
|
|
404
|
-
const
|
|
405
|
-
expect(
|
|
406
|
-
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);
|
|
407
246
|
} finally {
|
|
408
247
|
server.kill("SIGKILL");
|
|
409
248
|
}
|
|
410
249
|
});
|
|
411
250
|
|
|
412
251
|
test("a server behind a wrapper process is dumped too (pnpm exec / tsx shape)", async () => {
|
|
413
|
-
const dir = await
|
|
252
|
+
const dir = await scratch();
|
|
414
253
|
const wrapper = await start(dir, "wrapper.js", 2);
|
|
415
254
|
try {
|
|
416
255
|
expect((await socketsIn(dir)).length).toBe(2);
|
|
417
256
|
await node().capture!(fakeCtx(dir));
|
|
418
|
-
const reports = await reportsIn(dir);
|
|
419
|
-
expect(reports.length).toBe(2);
|
|
420
257
|
const urls = new Set<string>();
|
|
421
|
-
for (const
|
|
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
|
-
}
|
|
258
|
+
for (const n of await dumpsIn(dir)) for (const s of (await readDump(dir, n)).result) urls.add(s.url);
|
|
425
259
|
expect([...urls].some((u) => u.endsWith("/server.js"))).toBe(true);
|
|
426
260
|
expect([...urls].some((u) => u.endsWith("/wrapper.js"))).toBe(true);
|
|
427
261
|
} finally {
|
|
428
|
-
wrapper.kill("
|
|
429
|
-
|
|
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
|
-
}
|
|
262
|
+
wrapper.kill("SIGTERM");
|
|
263
|
+
await new Promise<void>((r) => wrapper.on("exit", () => r()));
|
|
436
264
|
}
|
|
437
265
|
});
|
|
438
266
|
|
|
439
|
-
test("short-lived CLI processes
|
|
440
|
-
const dir = await
|
|
441
|
-
|
|
442
|
-
await fs.writeFile(
|
|
443
|
-
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"] }));
|
|
444
271
|
// Nothing has run yet: an empty report, not a failure.
|
|
445
272
|
await node().capture!(fakeCtx(dir));
|
|
446
|
-
expect(await
|
|
447
|
-
|
|
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
|
-
}
|
|
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);
|
|
455
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);
|
|
456
295
|
await node().capture!(fakeCtx(dir));
|
|
457
|
-
|
|
458
|
-
expect(
|
|
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
|
|
296
|
+
expect((await dumpsIn(dir)).length).toBe(1);
|
|
297
|
+
expect((await records(dir)).size).toBe(0);
|
|
461
298
|
});
|
|
462
299
|
|
|
463
|
-
test("a
|
|
464
|
-
const dir = await
|
|
465
|
-
|
|
466
|
-
await fs.writeFile(
|
|
467
|
-
await fs.writeFile(
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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" },
|
|
476
315
|
});
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
}
|
|
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();
|
|
489
319
|
});
|
|
490
320
|
|
|
491
|
-
test("a
|
|
492
|
-
const dir = await
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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);
|
|
497
331
|
await node().capture!(fakeCtx(dir));
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
|
506
341
|
});
|
|
507
342
|
|
|
508
|
-
test("a
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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();
|
|
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);
|
|
544
351
|
});
|
|
545
352
|
|
|
546
353
|
test("no process and no dump: an empty report is written, once", async () => {
|
|
547
|
-
const dir = await
|
|
354
|
+
const dir = await scratch();
|
|
548
355
|
await node().capture!(fakeCtx(dir));
|
|
549
356
|
await node().capture!(fakeCtx(dir));
|
|
550
|
-
expect(await
|
|
357
|
+
expect((await fs.readdir(dir)).filter((n) => n.endsWith(".json"))).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
|
|
551
358
|
});
|
|
552
359
|
});
|