@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.
@@ -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. Sets `NODE_V8_COVERAGE` to the coverage
106
- * directory (every node process in the container then writes V8
107
- * coverage JSON when it exits) and `--require`s a hook that lets spectest
108
- * ask every live node process for a dump at capture time — the server,
109
- * and any wrapper it sits behind (`pnpm exec`, `tsx`). Nothing
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. Sets `NODE_V8_COVERAGE` to the coverage
146
- * directory (every node process in the container then writes V8
147
- * coverage JSON when it exits) and `--require`s a hook that lets spectest
148
- * ask every live node process for a dump at capture time — the server,
149
- * and any wrapper it sits behind (`pnpm exec`, `tsx`). Nothing
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
- const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
169
- if (live === 0) {
170
- throw new Error(`no node process is serving a ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_SOCKET_PREFIX}<pid> socket — does the service run node with the service's env (NODE_OPTIONS), and is it still alive?`);
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://") && !url.includes("/node_modules/");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.59.2",
3
+ "version": "0.59.3",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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 expect(node().capture!(fakeCtx(dir))).rejects.toThrow(/no node process is serving/);
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 server: capture fails naming the socket", async () => {
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 expect(node().capture!(fakeCtx(dir))).rejects.toThrow(/no node process is serving/);
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. Sets `NODE_V8_COVERAGE` to the coverage
230
- * directory (every node process in the container then writes V8
231
- * coverage JSON when it exits) and `--require`s a hook that lets spectest
232
- * ask every live node process for a dump at capture time — the server,
233
- * and any wrapper it sits behind (`pnpm exec`, `tsx`). Nothing
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
- const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
253
- if (live === 0) {
254
- throw new Error(
255
- `no node process is serving a ${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_SOCKET_PREFIX}<pid> socket — does the service run node with the service's env (NODE_OPTIONS), and is it still alive?`,
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 url.startsWith("file://") && !url.includes("/node_modules/");
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
  /**