@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.
- package/dist/coverage.d.ts +19 -1
- package/dist/coverage.js +79 -1
- package/package.json +1 -1
- package/src/coverage.test.ts +41 -0
- package/src/coverage.ts +75 -1
package/dist/coverage.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
package/src/coverage.test.ts
CHANGED
|
@@ -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.
|
|
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
|
/**
|