@specific.dev/spectest 0.59.2 → 0.59.3
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 +11 -6
- package/dist/coverage.js +38 -11
- package/package.json +1 -1
- package/src/coverage.test.ts +32 -5
- package/src/coverage.ts +43 -13
package/dist/coverage.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
|
3
3
|
export { COVERAGE_CONTAINER_DIR };
|
|
4
4
|
/** Where `node()` mounts its hook inside the container. */
|
|
5
5
|
export declare const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
6
|
+
/** The report `node()` writes when nothing has run yet on a branch. */
|
|
7
|
+
export declare const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
|
|
6
8
|
/** Prefix of the per-process sockets the node hook answers on, relative
|
|
7
9
|
* to the coverage dir: `.ctl-<pid>`. */
|
|
8
10
|
export declare const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
@@ -102,11 +104,13 @@ export declare function appendEnvFlag(env: Readonly<Record<string, string>> | un
|
|
|
102
104
|
*/
|
|
103
105
|
export declare const NODE_COVERAGE_HOOK: string;
|
|
104
106
|
/**
|
|
105
|
-
* Coverage for a Node service
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
107
|
+
* Coverage for a Node service — a long-lived server, or a container whose
|
|
108
|
+
* node processes are short-lived CLIs run by `ctx.exec`. Sets
|
|
109
|
+
* `NODE_V8_COVERAGE` to the coverage directory (every node process in the
|
|
110
|
+
* container then writes V8 coverage JSON when it exits) and `--require`s
|
|
111
|
+
* a hook that lets spectest ask every live node process for a dump at
|
|
112
|
+
* capture time — the server, and any wrapper it sits behind (`pnpm exec`,
|
|
113
|
+
* `tsx`). Nothing
|
|
110
114
|
* for the app to write. Each dump is compacted to the app's own scripts
|
|
111
115
|
* at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
|
|
112
116
|
* `sourceMappingURL` next to the file) Node records the map in the
|
|
@@ -120,7 +124,8 @@ export declare function node(): CoverageAdapter;
|
|
|
120
124
|
* answered with an error throws.
|
|
121
125
|
*/
|
|
122
126
|
export declare function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Promise<number>;
|
|
123
|
-
/** A script is the app's own when it is a file outside node_modules
|
|
127
|
+
/** A script is the app's own when it is a file outside node_modules —
|
|
128
|
+
* and not our hook, which every dump would otherwise carry. */
|
|
124
129
|
export declare function isAppScriptUrl(url: string): boolean;
|
|
125
130
|
/**
|
|
126
131
|
* Compact one V8 coverage document to the app's own scripts. What Node
|
package/dist/coverage.js
CHANGED
|
@@ -29,6 +29,8 @@ import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
|
29
29
|
export { COVERAGE_CONTAINER_DIR };
|
|
30
30
|
/** Where `node()` mounts its hook inside the container. */
|
|
31
31
|
export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
32
|
+
/** The report `node()` writes when nothing has run yet on a branch. */
|
|
33
|
+
export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
|
|
32
34
|
/** Prefix of the per-process sockets the node hook answers on, relative
|
|
33
35
|
* to the coverage dir: `.ctl-<pid>`. */
|
|
34
36
|
export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
@@ -142,11 +144,13 @@ if (process.env.NODE_V8_COVERAGE) {
|
|
|
142
144
|
}
|
|
143
145
|
`;
|
|
144
146
|
/**
|
|
145
|
-
* Coverage for a Node service
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
147
|
+
* Coverage for a Node service — a long-lived server, or a container whose
|
|
148
|
+
* node processes are short-lived CLIs run by `ctx.exec`. Sets
|
|
149
|
+
* `NODE_V8_COVERAGE` to the coverage directory (every node process in the
|
|
150
|
+
* container then writes V8 coverage JSON when it exits) and `--require`s
|
|
151
|
+
* a hook that lets spectest ask every live node process for a dump at
|
|
152
|
+
* capture time — the server, and any wrapper it sits behind (`pnpm exec`,
|
|
153
|
+
* `tsx`). Nothing
|
|
150
154
|
* for the app to write. Each dump is compacted to the app's own scripts
|
|
151
155
|
* at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
|
|
152
156
|
* `sourceMappingURL` next to the file) Node records the map in the
|
|
@@ -165,11 +169,20 @@ export function node() {
|
|
|
165
169
|
};
|
|
166
170
|
},
|
|
167
171
|
async capture(ctx) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
+
// Two shapes, one adapter: a long-lived server answers its socket
|
|
173
|
+
// here; a service whose node processes are short-lived (a CLI run
|
|
174
|
+
// by `ctx.exec` from a `sleep infinity` container) has no socket to
|
|
175
|
+
// answer and its coverage is the exit-time dumps already on disk.
|
|
176
|
+
// So no live process is not an error. No report at all — nothing
|
|
177
|
+
// has run yet on this branch — is recorded as an empty document
|
|
178
|
+
// rather than failed: an empty report is a report. A server whose
|
|
179
|
+
// hook never loaded (NODE_OPTIONS not reaching it) then shows as
|
|
180
|
+
// empty reports after bring-up, which the boot log warns about.
|
|
181
|
+
await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
|
|
172
182
|
await compactV8Reports(ctx.reportDir);
|
|
183
|
+
if (!(await hasV8Reports(ctx.reportDir))) {
|
|
184
|
+
await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
|
|
185
|
+
}
|
|
173
186
|
},
|
|
174
187
|
};
|
|
175
188
|
}
|
|
@@ -227,12 +240,26 @@ export async function dumpAllNodeProcesses(dir, signal) {
|
|
|
227
240
|
}
|
|
228
241
|
return live;
|
|
229
242
|
}
|
|
243
|
+
/** True when `dir` holds at least one `coverage-*.json` other than the
|
|
244
|
+
* empty placeholder. */
|
|
245
|
+
async function hasV8Reports(dir) {
|
|
246
|
+
const fs = await import("node:fs/promises");
|
|
247
|
+
try {
|
|
248
|
+
return (await fs.readdir(dir)).some((n) => n.startsWith("coverage-") && n.endsWith(".json") && n !== NODE_COVERAGE_EMPTY_REPORT);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
230
254
|
/** V8 files already compacted, by path. Module memory: forks with the
|
|
231
255
|
* environment, so a child never re-parses its ancestors' dumps. */
|
|
232
256
|
const COMPACTED_V8_REPORTS = new Set();
|
|
233
|
-
/** A script is the app's own when it is a file outside node_modules
|
|
257
|
+
/** A script is the app's own when it is a file outside node_modules —
|
|
258
|
+
* and not our hook, which every dump would otherwise carry. */
|
|
234
259
|
export function isAppScriptUrl(url) {
|
|
235
|
-
return url.startsWith("file://") &&
|
|
260
|
+
return (url.startsWith("file://") &&
|
|
261
|
+
!url.includes("/node_modules/") &&
|
|
262
|
+
!url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop()));
|
|
236
263
|
}
|
|
237
264
|
/**
|
|
238
265
|
* Compact one V8 coverage document to the app's own scripts. What Node
|
package/package.json
CHANGED
package/src/coverage.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import {
|
|
7
7
|
NODE_COVERAGE_HOOK,
|
|
8
|
+
NODE_COVERAGE_EMPTY_REPORT,
|
|
8
9
|
NODE_COVERAGE_HOOK_PATH,
|
|
9
10
|
NODE_COVERAGE_SOCKET_PREFIX,
|
|
10
11
|
applyCoverageAdapters,
|
|
@@ -157,7 +158,7 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
157
158
|
return (await fs.readdir(dir)).filter((n) => n.startsWith(NODE_COVERAGE_SOCKET_PREFIX)).sort();
|
|
158
159
|
}
|
|
159
160
|
async function reportsIn(dir: string): Promise<string[]> {
|
|
160
|
-
return (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-"));
|
|
161
|
+
return (await fs.readdir(dir)).filter((n) => n.startsWith("coverage-") && n.endsWith(".json"));
|
|
161
162
|
}
|
|
162
163
|
async function start(dir: string, entry: string, expectSockets: number): Promise<ChildProcess> {
|
|
163
164
|
const hook = path.join(dir, "hook.cjs");
|
|
@@ -231,25 +232,51 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
231
232
|
}
|
|
232
233
|
});
|
|
233
234
|
|
|
235
|
+
test("short-lived CLI processes: exit dumps are the coverage, no live process needed", async () => {
|
|
236
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
237
|
+
const hook = path.join(dir, "coverage-hook.cjs");
|
|
238
|
+
await fs.writeFile(hook, NODE_COVERAGE_HOOK);
|
|
239
|
+
await fs.writeFile(path.join(dir, "cli.js"), "process.stdout.write('v1');\n");
|
|
240
|
+
// Nothing has run yet: an empty report, not a failure.
|
|
241
|
+
await node().capture!(fakeCtx(dir));
|
|
242
|
+
expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
|
|
243
|
+
// Two CLI runs, each its own process, each dumping at exit.
|
|
244
|
+
for (let i = 0; i < 2; i++) {
|
|
245
|
+
const cli = spawn("node", [path.join(dir, "cli.js")], {
|
|
246
|
+
env: { ...process.env, NODE_V8_COVERAGE: dir, NODE_OPTIONS: `--require ${hook}` },
|
|
247
|
+
stdio: "ignore",
|
|
248
|
+
});
|
|
249
|
+
expect(await new Promise<number | null>((r) => cli.on("exit", r))).toBe(0);
|
|
250
|
+
}
|
|
251
|
+
expect((await socketsIn(dir)).length).toBe(0);
|
|
252
|
+
await node().capture!(fakeCtx(dir));
|
|
253
|
+
const reports = (await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT);
|
|
254
|
+
expect(reports.length).toBe(2);
|
|
255
|
+
const doc = JSON.parse(await fs.readFile(path.join(dir, reports[0]!), "utf8")) as { result: { url: string }[] };
|
|
256
|
+
expect(doc.result.map((x) => x.url)).toEqual([`file://${path.join(dir, "cli.js")}`]); // compacted
|
|
257
|
+
});
|
|
258
|
+
|
|
234
259
|
test("a stale socket left by a killed process is removed at capture", async () => {
|
|
235
260
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
236
261
|
const first = await start(dir, "server.js", 1);
|
|
237
262
|
first.kill("SIGKILL");
|
|
238
263
|
await new Promise<void>((resolve) => first.on("exit", () => resolve()));
|
|
239
264
|
expect((await socketsIn(dir)).length).toBe(1); // stale
|
|
240
|
-
await
|
|
265
|
+
await node().capture!(fakeCtx(dir));
|
|
241
266
|
expect((await socketsIn(dir)).length).toBe(0); // cleaned up
|
|
242
267
|
const second = await start(dir, "server.js", 1);
|
|
243
268
|
try {
|
|
244
269
|
await node().capture!(fakeCtx(dir));
|
|
245
|
-
expect((await reportsIn(dir)).length).toBe(1);
|
|
270
|
+
expect((await reportsIn(dir)).filter((n) => n !== NODE_COVERAGE_EMPTY_REPORT).length).toBe(1);
|
|
246
271
|
} finally {
|
|
247
272
|
second.kill("SIGKILL");
|
|
248
273
|
}
|
|
249
274
|
});
|
|
250
275
|
|
|
251
|
-
test("no
|
|
276
|
+
test("no process and no dump: an empty report is written, once", async () => {
|
|
252
277
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-covhook-"));
|
|
253
|
-
await
|
|
278
|
+
await node().capture!(fakeCtx(dir));
|
|
279
|
+
await node().capture!(fakeCtx(dir));
|
|
280
|
+
expect(await reportsIn(dir)).toEqual([NODE_COVERAGE_EMPTY_REPORT]);
|
|
254
281
|
});
|
|
255
282
|
});
|
package/src/coverage.ts
CHANGED
|
@@ -34,6 +34,9 @@ export { COVERAGE_CONTAINER_DIR };
|
|
|
34
34
|
/** Where `node()` mounts its hook inside the container. */
|
|
35
35
|
export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
36
36
|
|
|
37
|
+
/** The report `node()` writes when nothing has run yet on a branch. */
|
|
38
|
+
export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
|
|
39
|
+
|
|
37
40
|
/** Prefix of the per-process sockets the node hook answers on, relative
|
|
38
41
|
* to the coverage dir: `.ctl-<pid>`. */
|
|
39
42
|
export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
@@ -226,11 +229,13 @@ if (process.env.NODE_V8_COVERAGE) {
|
|
|
226
229
|
`;
|
|
227
230
|
|
|
228
231
|
/**
|
|
229
|
-
* Coverage for a Node service
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
232
|
+
* Coverage for a Node service — a long-lived server, or a container whose
|
|
233
|
+
* node processes are short-lived CLIs run by `ctx.exec`. Sets
|
|
234
|
+
* `NODE_V8_COVERAGE` to the coverage directory (every node process in the
|
|
235
|
+
* container then writes V8 coverage JSON when it exits) and `--require`s
|
|
236
|
+
* a hook that lets spectest ask every live node process for a dump at
|
|
237
|
+
* capture time — the server, and any wrapper it sits behind (`pnpm exec`,
|
|
238
|
+
* `tsx`). Nothing
|
|
234
239
|
* for the app to write. Each dump is compacted to the app's own scripts
|
|
235
240
|
* at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
|
|
236
241
|
* `sourceMappingURL` next to the file) Node records the map in the
|
|
@@ -249,13 +254,20 @@ export function node(): CoverageAdapter {
|
|
|
249
254
|
};
|
|
250
255
|
},
|
|
251
256
|
async capture(ctx) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
257
|
+
// Two shapes, one adapter: a long-lived server answers its socket
|
|
258
|
+
// here; a service whose node processes are short-lived (a CLI run
|
|
259
|
+
// by `ctx.exec` from a `sleep infinity` container) has no socket to
|
|
260
|
+
// answer and its coverage is the exit-time dumps already on disk.
|
|
261
|
+
// So no live process is not an error. No report at all — nothing
|
|
262
|
+
// has run yet on this branch — is recorded as an empty document
|
|
263
|
+
// rather than failed: an empty report is a report. A server whose
|
|
264
|
+
// hook never loaded (NODE_OPTIONS not reaching it) then shows as
|
|
265
|
+
// empty reports after bring-up, which the boot log warns about.
|
|
266
|
+
await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
|
|
258
267
|
await compactV8Reports(ctx.reportDir);
|
|
268
|
+
if (!(await hasV8Reports(ctx.reportDir))) {
|
|
269
|
+
await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
|
|
270
|
+
}
|
|
259
271
|
},
|
|
260
272
|
};
|
|
261
273
|
}
|
|
@@ -310,13 +322,31 @@ export async function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Pr
|
|
|
310
322
|
return live;
|
|
311
323
|
}
|
|
312
324
|
|
|
325
|
+
/** True when `dir` holds at least one `coverage-*.json` other than the
|
|
326
|
+
* empty placeholder. */
|
|
327
|
+
async function hasV8Reports(dir: string): Promise<boolean> {
|
|
328
|
+
const fs = await import("node:fs/promises");
|
|
329
|
+
try {
|
|
330
|
+
return (await fs.readdir(dir)).some(
|
|
331
|
+
(n) => n.startsWith("coverage-") && n.endsWith(".json") && n !== NODE_COVERAGE_EMPTY_REPORT,
|
|
332
|
+
);
|
|
333
|
+
} catch {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
313
338
|
/** V8 files already compacted, by path. Module memory: forks with the
|
|
314
339
|
* environment, so a child never re-parses its ancestors' dumps. */
|
|
315
340
|
const COMPACTED_V8_REPORTS = new Set<string>();
|
|
316
341
|
|
|
317
|
-
/** A script is the app's own when it is a file outside node_modules
|
|
342
|
+
/** A script is the app's own when it is a file outside node_modules —
|
|
343
|
+
* and not our hook, which every dump would otherwise carry. */
|
|
318
344
|
export function isAppScriptUrl(url: string): boolean {
|
|
319
|
-
return
|
|
345
|
+
return (
|
|
346
|
+
url.startsWith("file://") &&
|
|
347
|
+
!url.includes("/node_modules/") &&
|
|
348
|
+
!url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop())
|
|
349
|
+
);
|
|
320
350
|
}
|
|
321
351
|
|
|
322
352
|
/**
|