@specific.dev/spectest 0.59.0 → 0.59.1

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.
@@ -102,11 +102,29 @@ export declare const NODE_COVERAGE_HOOK: string;
102
102
  * directory (every node process in the container then writes V8
103
103
  * coverage JSON when it exits) and `--require`s a hook that lets spectest
104
104
  * ask the long-lived server process for a dump at capture time. Nothing
105
- * for the app to write. Source maps: with `--enable-source-maps` (or a
105
+ * for the app to write. Each dump is compacted to the app's own scripts
106
+ * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
106
107
  * `sourceMappingURL` next to the file) Node records the map in the
107
108
  * report, which is what maps a TypeScript service back to its sources.
108
109
  */
109
110
  export declare function node(): CoverageAdapter;
111
+ /** A script is the app's own when it is a file outside node_modules. */
112
+ export declare function isAppScriptUrl(url: string): boolean;
113
+ /**
114
+ * Compact one V8 coverage document to the app's own scripts. What Node
115
+ * writes is everything the process loaded: `node:` internals, every
116
+ * `node_modules` file, and — under `--enable-source-maps` — a
117
+ * `source-map-cache` with each file's full map **and its sources**,
118
+ * repeated in every dump. Measured on a real project: a 12–20 MiB dump
119
+ * per capture, of which the app's own coverage was under 0.5 MiB, and a
120
+ * suite that hit the 64 MiB cap on its third test. Kept: `file://`
121
+ * scripts outside `node_modules`, the map entries of exactly those
122
+ * scripts, minus `sourcesContent` (the sources are the repo). Still a V8
123
+ * document — nothing is converted.
124
+ */
125
+ export declare function compactV8Document(doc: Record<string, unknown>): Record<string, unknown>;
126
+ /** Compact every not-yet-compacted V8 document in `dir`, in place. */
127
+ export declare function compactV8Reports(dir: string): Promise<void>;
110
128
  /**
111
129
  * Coverage for the frontend a service serves. The code runs in the guest
112
130
  * browser — spectest's own process — so no report can be written by the
package/dist/coverage.js CHANGED
@@ -151,7 +151,8 @@ if (process.env.NODE_V8_COVERAGE) {
151
151
  * directory (every node process in the container then writes V8
152
152
  * coverage JSON when it exits) and `--require`s a hook that lets spectest
153
153
  * ask the long-lived server process for a dump at capture time. Nothing
154
- * for the app to write. Source maps: with `--enable-source-maps` (or a
154
+ * for the app to write. Each dump is compacted to the app's own scripts
155
+ * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
155
156
  * `sourceMappingURL` next to the file) Node records the map in the
156
157
  * report, which is what maps a TypeScript service back to its sources.
157
158
  */
@@ -193,9 +194,86 @@ export function node() {
193
194
  });
194
195
  if (reply !== "ok")
195
196
  throw new Error(`the node hook answered: ${reply || "(nothing)"}`);
197
+ await compactV8Reports(ctx.reportDir);
196
198
  },
197
199
  };
198
200
  }
201
+ /** V8 files already compacted, by path. Module memory: forks with the
202
+ * environment, so a child never re-parses its ancestors' dumps. */
203
+ const COMPACTED_V8_REPORTS = new Set();
204
+ /** A script is the app's own when it is a file outside node_modules. */
205
+ export function isAppScriptUrl(url) {
206
+ return url.startsWith("file://") && !url.includes("/node_modules/");
207
+ }
208
+ /**
209
+ * Compact one V8 coverage document to the app's own scripts. What Node
210
+ * writes is everything the process loaded: `node:` internals, every
211
+ * `node_modules` file, and — under `--enable-source-maps` — a
212
+ * `source-map-cache` with each file's full map **and its sources**,
213
+ * repeated in every dump. Measured on a real project: a 12–20 MiB dump
214
+ * per capture, of which the app's own coverage was under 0.5 MiB, and a
215
+ * suite that hit the 64 MiB cap on its third test. Kept: `file://`
216
+ * scripts outside `node_modules`, the map entries of exactly those
217
+ * scripts, minus `sourcesContent` (the sources are the repo). Still a V8
218
+ * document — nothing is converted.
219
+ */
220
+ export function compactV8Document(doc) {
221
+ const result = Array.isArray(doc.result) ? doc.result : [];
222
+ const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url));
223
+ const out = { ...doc, result: kept };
224
+ const cache = doc["source-map-cache"];
225
+ if (cache && typeof cache === "object") {
226
+ const urls = new Set(kept.map((s) => s.url));
227
+ const slim = {};
228
+ for (const [url, entry] of Object.entries(cache)) {
229
+ if (!urls.has(url) || !entry || typeof entry !== "object")
230
+ continue;
231
+ const e = { ...entry };
232
+ if (e.data && typeof e.data === "object") {
233
+ const { sourcesContent: _dropped, ...data } = e.data;
234
+ e.data = data;
235
+ }
236
+ slim[url] = e;
237
+ }
238
+ if (Object.keys(slim).length > 0)
239
+ out["source-map-cache"] = slim;
240
+ else
241
+ delete out["source-map-cache"];
242
+ }
243
+ return out;
244
+ }
245
+ /** Compact every not-yet-compacted V8 document in `dir`, in place. */
246
+ export async function compactV8Reports(dir) {
247
+ const fs = await import("node:fs/promises");
248
+ const path = await import("node:path");
249
+ let names;
250
+ try {
251
+ names = await fs.readdir(dir);
252
+ }
253
+ catch {
254
+ return;
255
+ }
256
+ for (const name of names) {
257
+ if (!name.startsWith("coverage-") || !name.endsWith(".json"))
258
+ continue;
259
+ const file = path.join(dir, name);
260
+ if (COMPACTED_V8_REPORTS.has(file))
261
+ continue;
262
+ let doc;
263
+ try {
264
+ doc = JSON.parse(await fs.readFile(file, "utf8"));
265
+ }
266
+ catch {
267
+ continue; // a dump mid-write, or not ours; the read path judges it
268
+ }
269
+ if (!Array.isArray(doc.result))
270
+ continue;
271
+ const tmp = path.join(dir, `.${name}.compact`);
272
+ await fs.writeFile(tmp, JSON.stringify(compactV8Document(doc)));
273
+ await fs.rename(tmp, file);
274
+ COMPACTED_V8_REPORTS.add(file);
275
+ }
276
+ }
199
277
  // ── browser ───────────────────────────────────────────────────────
200
278
  /**
201
279
  * Coverage for the frontend a service serves. The code runs in the guest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.59.0",
3
+ "version": "0.59.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -11,6 +11,8 @@ import {
11
11
  appendEnvFlag,
12
12
  browser,
13
13
  command,
14
+ compactV8Document,
15
+ compactV8Reports,
14
16
  node,
15
17
  validateCoverage,
16
18
  type CoverageCaptureContext,
@@ -68,6 +70,45 @@ describe("applyCoverageAdapters", () => {
68
70
  });
69
71
  });
70
72
 
73
+ describe("compactV8Document", () => {
74
+ const doc = {
75
+ result: [
76
+ { scriptId: "1", url: "file:///app/src/a.ts", functions: [] },
77
+ { scriptId: "2", url: "file:///app/node_modules/x/index.js", functions: [] },
78
+ { scriptId: "3", url: "node:fs", functions: [] },
79
+ { scriptId: "4", url: "", functions: [] },
80
+ ],
81
+ "source-map-cache": {
82
+ "file:///app/src/a.ts": { lineLengths: [1], data: { mappings: "AAAA", sources: ["a.ts"], sourcesContent: ["x"] }, url: null },
83
+ "file:///app/node_modules/x/index.js": { lineLengths: [1], data: { mappings: "AAAA" }, url: null },
84
+ },
85
+ };
86
+ test("keeps app scripts and their maps, drops the rest and sourcesContent", () => {
87
+ const out = compactV8Document(doc) as typeof doc;
88
+ expect(out.result.map((s) => s.url)).toEqual(["file:///app/src/a.ts"]);
89
+ expect(Object.keys(out["source-map-cache"])).toEqual(["file:///app/src/a.ts"]);
90
+ expect(out["source-map-cache"]["file:///app/src/a.ts"].data).toEqual({ mappings: "AAAA", sources: ["a.ts"] });
91
+ });
92
+ test("a dump with no app script keeps an empty result and no cache", () => {
93
+ const out = compactV8Document({ result: [doc.result[2]], "source-map-cache": doc["source-map-cache"] });
94
+ expect(out.result).toEqual([]);
95
+ expect("source-map-cache" in out).toBe(false);
96
+ });
97
+ test("compactV8Reports rewrites coverage-*.json once and leaves other files alone", async () => {
98
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
99
+ await fs.writeFile(path.join(dir, "coverage-1-2-0.json"), JSON.stringify(doc));
100
+ await fs.writeFile(path.join(dir, "browser.lcov"), "TN:\n");
101
+ await compactV8Reports(dir);
102
+ const back = JSON.parse(await fs.readFile(path.join(dir, "coverage-1-2-0.json"), "utf8")) as { result: unknown[] };
103
+ expect(back.result.length).toBe(1);
104
+ expect(await fs.readFile(path.join(dir, "browser.lcov"), "utf8")).toBe("TN:\n");
105
+ // Idempotent and cheap: a second pass is a no-op on the same file.
106
+ await fs.writeFile(path.join(dir, "coverage-1-2-0.json"), "not json");
107
+ await compactV8Reports(dir);
108
+ expect(await fs.readFile(path.join(dir, "coverage-1-2-0.json"), "utf8")).toBe("not json");
109
+ });
110
+ });
111
+
71
112
  /** A capture context over a real directory, no container. */
72
113
  function fakeCtx(dir: string, signal = new AbortController().signal): CoverageCaptureContext {
73
114
  return {
package/src/coverage.ts CHANGED
@@ -235,7 +235,8 @@ if (process.env.NODE_V8_COVERAGE) {
235
235
  * directory (every node process in the container then writes V8
236
236
  * coverage JSON when it exits) and `--require`s a hook that lets spectest
237
237
  * ask the long-lived server process for a dump at capture time. Nothing
238
- * for the app to write. Source maps: with `--enable-source-maps` (or a
238
+ * for the app to write. Each dump is compacted to the app's own scripts
239
+ * at capture ({@link compactV8Document}). Source maps: with `--enable-source-maps` (or a
239
240
  * `sourceMappingURL` next to the file) Node records the map in the
240
241
  * report, which is what maps a TypeScript service back to its sources.
241
242
  */
@@ -280,10 +281,83 @@ export function node(): CoverageAdapter {
280
281
  });
281
282
  });
282
283
  if (reply !== "ok") throw new Error(`the node hook answered: ${reply || "(nothing)"}`);
284
+ await compactV8Reports(ctx.reportDir);
283
285
  },
284
286
  };
285
287
  }
286
288
 
289
+ /** V8 files already compacted, by path. Module memory: forks with the
290
+ * environment, so a child never re-parses its ancestors' dumps. */
291
+ const COMPACTED_V8_REPORTS = new Set<string>();
292
+
293
+ /** A script is the app's own when it is a file outside node_modules. */
294
+ export function isAppScriptUrl(url: string): boolean {
295
+ return url.startsWith("file://") && !url.includes("/node_modules/");
296
+ }
297
+
298
+ /**
299
+ * Compact one V8 coverage document to the app's own scripts. What Node
300
+ * writes is everything the process loaded: `node:` internals, every
301
+ * `node_modules` file, and — under `--enable-source-maps` — a
302
+ * `source-map-cache` with each file's full map **and its sources**,
303
+ * repeated in every dump. Measured on a real project: a 12–20 MiB dump
304
+ * per capture, of which the app's own coverage was under 0.5 MiB, and a
305
+ * suite that hit the 64 MiB cap on its third test. Kept: `file://`
306
+ * scripts outside `node_modules`, the map entries of exactly those
307
+ * scripts, minus `sourcesContent` (the sources are the repo). Still a V8
308
+ * document — nothing is converted.
309
+ */
310
+ export function compactV8Document(doc: Record<string, unknown>): Record<string, unknown> {
311
+ const result = Array.isArray(doc.result) ? (doc.result as { url?: unknown }[]) : [];
312
+ const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url));
313
+ const out: Record<string, unknown> = { ...doc, result: kept };
314
+ const cache = doc["source-map-cache"];
315
+ if (cache && typeof cache === "object") {
316
+ const urls = new Set(kept.map((s) => s.url as string));
317
+ const slim: Record<string, unknown> = {};
318
+ for (const [url, entry] of Object.entries(cache as Record<string, unknown>)) {
319
+ if (!urls.has(url) || !entry || typeof entry !== "object") continue;
320
+ const e = { ...(entry as Record<string, unknown>) };
321
+ if (e.data && typeof e.data === "object") {
322
+ const { sourcesContent: _dropped, ...data } = e.data as Record<string, unknown>;
323
+ e.data = data;
324
+ }
325
+ slim[url] = e;
326
+ }
327
+ if (Object.keys(slim).length > 0) out["source-map-cache"] = slim;
328
+ else delete out["source-map-cache"];
329
+ }
330
+ return out;
331
+ }
332
+
333
+ /** Compact every not-yet-compacted V8 document in `dir`, in place. */
334
+ export async function compactV8Reports(dir: string): Promise<void> {
335
+ const fs = await import("node:fs/promises");
336
+ const path = await import("node:path");
337
+ let names: string[];
338
+ try {
339
+ names = await fs.readdir(dir);
340
+ } catch {
341
+ return;
342
+ }
343
+ for (const name of names) {
344
+ if (!name.startsWith("coverage-") || !name.endsWith(".json")) continue;
345
+ const file = path.join(dir, name);
346
+ if (COMPACTED_V8_REPORTS.has(file)) continue;
347
+ let doc: Record<string, unknown>;
348
+ try {
349
+ doc = JSON.parse(await fs.readFile(file, "utf8")) as Record<string, unknown>;
350
+ } catch {
351
+ continue; // a dump mid-write, or not ours; the read path judges it
352
+ }
353
+ if (!Array.isArray(doc.result)) continue;
354
+ const tmp = path.join(dir, `.${name}.compact`);
355
+ await fs.writeFile(tmp, JSON.stringify(compactV8Document(doc)));
356
+ await fs.rename(tmp, file);
357
+ COMPACTED_V8_REPORTS.add(file);
358
+ }
359
+ }
360
+
287
361
  // ── browser ───────────────────────────────────────────────────────
288
362
 
289
363
  /**