@specific.dev/spectest 0.79.2 → 0.81.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 +111 -69
- package/dist/coverage.js +280 -171
- package/dist/daemon.js +24 -14
- package/package.json +1 -1
- package/src/coverage.test.ts +198 -131
- package/src/coverage.ts +320 -177
- package/src/daemon.ts +20 -16
package/dist/coverage.js
CHANGED
|
@@ -14,11 +14,10 @@
|
|
|
14
14
|
//
|
|
15
15
|
// After every adapter ran, the harness reads the directory the same way
|
|
16
16
|
// it does for `coverage: true`: at least one well-formed report (lcov or
|
|
17
|
-
// V8 JSON), shipped verbatim.
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// only the container has.
|
|
17
|
+
// V8 JSON), shipped verbatim. Nothing is converted in the VM: `node()`
|
|
18
|
+
// compacts its V8 dumps (the app's own scripts, each source map once per
|
|
19
|
+
// branch) and ships them as they are. The control plane derives what it
|
|
20
|
+
// needs from the stored bytes, off the test run.
|
|
22
21
|
//
|
|
23
22
|
// Imported from `@specific.dev/spectest/coverage`. The three shipped
|
|
24
23
|
// adapters: `node()` (V8 JSON through a hook spectest mounts), `browser()`
|
|
@@ -27,6 +26,7 @@
|
|
|
27
26
|
//
|
|
28
27
|
// import * as coverage from "@specific.dev/spectest/coverage";
|
|
29
28
|
// coverage: { adapters: [coverage.node(), coverage.browser()] }
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
30
|
import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
|
|
31
31
|
import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
|
|
32
32
|
export { COVERAGE_CONTAINER_DIR };
|
|
@@ -37,22 +37,11 @@ export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
|
|
|
37
37
|
/** Prefix of the per-process sockets the node hook answers on, relative
|
|
38
38
|
* to the coverage dir: `.ctl-<pid>`. */
|
|
39
39
|
export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
export const
|
|
45
|
-
/** Where the container sees {@link NODE_COVERAGE_TOOLS_DIR}. */
|
|
46
|
-
export const NODE_COVERAGE_TOOLS_CONTAINER_DIR = "/spectest/coverage-tools";
|
|
47
|
-
/** The `c8` entry point, relative to the tools dir. */
|
|
48
|
-
export const NODE_COVERAGE_C8_BIN = "node_modules/c8/bin/c8.js";
|
|
49
|
-
/** The hidden subdirectory of the coverage dir where `node()` parks the
|
|
50
|
-
* V8 dumps it converts at one capture. Hidden, so the harness never
|
|
51
|
-
* ships them: the lcov is the report. Emptied at every capture — the
|
|
52
|
-
* lcov is this capture's delta, see {@link convertV8ReportsToLcov}. */
|
|
53
|
-
export const NODE_COVERAGE_DUMPS_SUBDIR = ".v8";
|
|
54
|
-
/** The lcov `node()` writes: the dumps of this capture, merged. */
|
|
55
|
-
export const NODE_COVERAGE_LCOV = "node.lcov";
|
|
40
|
+
/** Key of the content hash (`sha256:<hex>`) a shipped `source-map-cache`
|
|
41
|
+
* entry carries, and key of the stub that stands in for an entry that
|
|
42
|
+
* shipped earlier on the same branch. See {@link compactV8Document}. */
|
|
43
|
+
export const SOURCE_MAP_HASH_KEY = "spectestHash";
|
|
44
|
+
export const SOURCE_MAP_REF_KEY = "spectestRef";
|
|
56
45
|
/** The `reports` mode of a `coverage` value, for the reports no adapter
|
|
57
46
|
* wrote (the program's own, a `command`'s). */
|
|
58
47
|
export function coverageReportsMode(cov) {
|
|
@@ -193,23 +182,26 @@ if (process.env.NODE_V8_COVERAGE && require("node:worker_threads").isMainThread)
|
|
|
193
182
|
* capture time — the server, and any wrapper it sits behind (`pnpm exec`,
|
|
194
183
|
* `tsx`). Nothing for the app to write.
|
|
195
184
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
185
|
+
* What ships is the V8 documents themselves, compacted
|
|
186
|
+
* ({@link compactV8Document}): the app's own scripts that ran, and each
|
|
187
|
+
* script's source map once per branch. A map comes from Node's own
|
|
188
|
+
* `source-map-cache` when Node found one through the script's
|
|
189
|
+
* `sourceMappingURL` (it caches maps under NODE_V8_COVERAGE with or
|
|
190
|
+
* without `--enable-source-maps`), else from `<script>.map` next to the
|
|
191
|
+
* script, read out of the container once
|
|
192
|
+
* ({@link resolveSourceMapFromContainer}). The second way is the one to
|
|
193
|
+
* build a short-lived bundled process in: a map Node finds is serialized
|
|
194
|
+
* whole into every exit dump — 12 MB per invocation for a bundled CLI,
|
|
195
|
+
* inside the test's own time — and parsed again by the harness; a map
|
|
196
|
+
* with no comment costs nothing there. Nothing is converted in the VM.
|
|
197
|
+
* A dump is a **delta** by construction: V8 resets its counters at every
|
|
198
|
+
* `takeCoverage()`, so a live server's dump after a test is that test's
|
|
199
|
+
* own execution, and the boot dump (everything loaded) lands in the
|
|
200
|
+
* bring-up capture and nowhere else. The control plane derives lines
|
|
201
|
+
* from the stored documents, off the test run. Converting to lcov in
|
|
202
|
+
* the container (`c8`, SDK 0.60 to 0.79) took seconds per capture per
|
|
203
|
+
* service on a real project, inside the test run; a real project turned
|
|
204
|
+
* coverage off because of it, and the conversion went.
|
|
213
205
|
*/
|
|
214
206
|
export function node() {
|
|
215
207
|
return {
|
|
@@ -234,114 +226,26 @@ export function node() {
|
|
|
234
226
|
// rather than failed: an empty report is a report. A server whose
|
|
235
227
|
// hook never loaded (NODE_OPTIONS not reaching it) then shows as
|
|
236
228
|
// empty reports after bring-up, which the boot log warns about.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
await
|
|
243
|
-
|
|
229
|
+
//
|
|
230
|
+
// Each capture ships only its own dumps: what an earlier capture on
|
|
231
|
+
// this branch shipped is removed first (module memory, forks with
|
|
232
|
+
// the environment).
|
|
233
|
+
const t0 = performance.now();
|
|
234
|
+
await removeShippedV8Reports(ctx.reportDir);
|
|
235
|
+
const live = await dumpAllNodeProcesses(ctx.reportDir, ctx.signal);
|
|
236
|
+
const t1 = performance.now();
|
|
237
|
+
const stats = await prepareV8Reports(ctx.reportDir, (url) => resolveSourceMapFromContainer(ctx, url));
|
|
238
|
+
if (stats.dumps === 0) {
|
|
244
239
|
await ctx.writeReport(NODE_COVERAGE_EMPTY_REPORT, '{"result":[]}\n');
|
|
240
|
+
SHIPPED_V8_REPORTS.add((await import("node:path")).join(ctx.reportDir, NODE_COVERAGE_EMPTY_REPORT));
|
|
245
241
|
}
|
|
242
|
+
const t2 = performance.now();
|
|
243
|
+
console.log(`[coverage] node/${ctx.service}: ${live} live process(es) dumped in ${Math.round(t1 - t0)} ms; ` +
|
|
244
|
+
`${stats.dumps} dump(s), ${stats.scripts} script(s), ${stats.bytesIn} → ${stats.bytesOut} bytes compacted in ${Math.round(t2 - t1)} ms; ` +
|
|
245
|
+
`${stats.mapsFromContainer} map(s) read from the container`);
|
|
246
246
|
},
|
|
247
247
|
};
|
|
248
248
|
}
|
|
249
|
-
/** The VM-side tools dir, after the test override. */
|
|
250
|
-
export function nodeCoverageToolsDir() {
|
|
251
|
-
return process.env.SPECTEST_COVERAGE_TOOLS_DIR || NODE_COVERAGE_TOOLS_DIR;
|
|
252
|
-
}
|
|
253
|
-
/** True when golden (or the override) ships `c8`. The daemon mounts the
|
|
254
|
-
* directory into covered containers on the same check, so what the VM
|
|
255
|
-
* has is what the container sees. */
|
|
256
|
-
export async function nodeCoverageToolsAvailable() {
|
|
257
|
-
const fs = await import("node:fs/promises");
|
|
258
|
-
const path = await import("node:path");
|
|
259
|
-
try {
|
|
260
|
-
await fs.access(path.join(nodeCoverageToolsDir(), NODE_COVERAGE_C8_BIN));
|
|
261
|
-
return true;
|
|
262
|
-
}
|
|
263
|
-
catch {
|
|
264
|
-
return false;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
/** The command `convertV8ReportsToLcov` runs in the container. From `/`,
|
|
268
|
-
* so lcov paths come out relative to the root (made absolute after). */
|
|
269
|
-
export function nodeCoverageConvertCommand() {
|
|
270
|
-
const c8 = `${NODE_COVERAGE_TOOLS_CONTAINER_DIR}/${NODE_COVERAGE_C8_BIN}`;
|
|
271
|
-
const dumps = `${COVERAGE_CONTAINER_DIR}/${NODE_COVERAGE_DUMPS_SUBDIR}`;
|
|
272
|
-
const out = `${COVERAGE_CONTAINER_DIR}/.c8`;
|
|
273
|
-
// `env -u`: the converter is a node process in a container whose env
|
|
274
|
-
// carries NODE_V8_COVERAGE and the `--require` hook, so without this it
|
|
275
|
-
// would load the hook and write a dump of itself at exit — a raw V8
|
|
276
|
-
// document at the root of the dir, shipped as a report (seen on the
|
|
277
|
-
// first run of hello-node under 0.60).
|
|
278
|
-
return `cd / && env -u NODE_V8_COVERAGE -u NODE_OPTIONS node ${c8} report --temp-directory ${dumps} --reporter=lcovonly --reports-dir ${out}`;
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
|
-
* Convert the V8 dumps written since the previous capture to one lcov,
|
|
282
|
-
* `node.lcov`. New dumps at the root of the dir are compacted to the
|
|
283
|
-
* app's scripts (their maps and `sourcesContent` kept — `v8-to-istanbul`
|
|
284
|
-
* reads the original sources from there when they are not on disk, the
|
|
285
|
-
* usual shape of a multi-stage image) and moved into `.v8/`, which is
|
|
286
|
-
* emptied first; `c8 report` merges that directory. So the lcov is this
|
|
287
|
-
* capture's delta: what the live processes ran since their last take,
|
|
288
|
-
* plus every process that exited since. No new dump ⇒ `TN:` alone,
|
|
289
|
-
* nothing ran. The `SF:` paths come out relative to `/` and are made
|
|
290
|
-
* absolute, so they read as container paths like every other tool's.
|
|
291
|
-
*/
|
|
292
|
-
export async function convertV8ReportsToLcov(ctx) {
|
|
293
|
-
const fs = await import("node:fs/promises");
|
|
294
|
-
const path = await import("node:path");
|
|
295
|
-
const dumpsDir = path.join(ctx.reportDir, NODE_COVERAGE_DUMPS_SUBDIR);
|
|
296
|
-
await fs.rm(dumpsDir, { recursive: true, force: true });
|
|
297
|
-
await fs.mkdir(dumpsDir, { recursive: true });
|
|
298
|
-
await fs.chmod(dumpsDir, 0o755);
|
|
299
|
-
let dumps = 0;
|
|
300
|
-
for (const name of await fs.readdir(ctx.reportDir)) {
|
|
301
|
-
if (!name.startsWith("coverage-") || !name.endsWith(".json"))
|
|
302
|
-
continue;
|
|
303
|
-
const file = path.join(ctx.reportDir, name);
|
|
304
|
-
if (name === NODE_COVERAGE_EMPTY_REPORT) {
|
|
305
|
-
await fs.unlink(file).catch(() => { }); // a pre-0.60 placeholder
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
let doc;
|
|
309
|
-
try {
|
|
310
|
-
doc = JSON.parse(await fs.readFile(file, "utf8"));
|
|
311
|
-
}
|
|
312
|
-
catch {
|
|
313
|
-
continue; // a dump mid-write; the next capture takes it
|
|
314
|
-
}
|
|
315
|
-
if (!Array.isArray(doc.result))
|
|
316
|
-
continue;
|
|
317
|
-
const target = path.join(dumpsDir, name);
|
|
318
|
-
await fs.writeFile(`${target}.tmp`, JSON.stringify(compactV8Document(doc, { keepSourcesContent: true })));
|
|
319
|
-
await fs.chmod(`${target}.tmp`, 0o644);
|
|
320
|
-
await fs.rename(`${target}.tmp`, target);
|
|
321
|
-
await fs.unlink(file);
|
|
322
|
-
dumps++;
|
|
323
|
-
}
|
|
324
|
-
if (dumps === 0) {
|
|
325
|
-
await ctx.writeReport(NODE_COVERAGE_LCOV, "TN:\n");
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
await ctx.exec(nodeCoverageConvertCommand());
|
|
329
|
-
let lcov;
|
|
330
|
-
try {
|
|
331
|
-
lcov = await fs.readFile(path.join(ctx.reportDir, ".c8", "lcov.info"), "utf8");
|
|
332
|
-
}
|
|
333
|
-
catch (err) {
|
|
334
|
-
throw new Error(`c8 wrote no lcov.info: ${err instanceof Error ? err.message : String(err)}`);
|
|
335
|
-
}
|
|
336
|
-
await ctx.writeReport(NODE_COVERAGE_LCOV, absoluteLcovPaths(lcov));
|
|
337
|
-
}
|
|
338
|
-
/** `SF:` records relative to `/` (what `c8` run from `/` writes) made
|
|
339
|
-
* absolute. An lcov with nothing in it becomes the empty report. */
|
|
340
|
-
export function absoluteLcovPaths(lcov) {
|
|
341
|
-
if (lcov.trim().length === 0)
|
|
342
|
-
return "TN:\n";
|
|
343
|
-
return lcov.replace(/^SF:(?!\/)/gm, "SF:/");
|
|
344
|
-
}
|
|
345
249
|
/**
|
|
346
250
|
* Ask every node process that holds a hook socket in `dir` for a dump.
|
|
347
251
|
* A socket nobody answers (its process died without unlinking — SIGKILL,
|
|
@@ -396,20 +300,36 @@ export async function dumpAllNodeProcesses(dir, signal) {
|
|
|
396
300
|
}
|
|
397
301
|
return live;
|
|
398
302
|
}
|
|
399
|
-
/**
|
|
400
|
-
*
|
|
401
|
-
|
|
303
|
+
/** Report files shipped at an earlier capture on this branch, by host
|
|
304
|
+
* path. Module memory: forks with the environment, so a forked child
|
|
305
|
+
* removes its ancestors' reports at its first capture and ships only
|
|
306
|
+
* its own. */
|
|
307
|
+
const SHIPPED_V8_REPORTS = new Set();
|
|
308
|
+
/** Source-map entries shipped earlier on this branch: script URL → hash
|
|
309
|
+
* of the entry. Module memory, forks with the environment. A later dump
|
|
310
|
+
* of the same script carries a stub that refers to the hash instead of
|
|
311
|
+
* the map again. */
|
|
312
|
+
const SHIPPED_SOURCE_MAPS = new Map();
|
|
313
|
+
/** Scripts looked up in the container and found to have no source map
|
|
314
|
+
* (no `sourceMappingURL`, or a map file that is not there). Module
|
|
315
|
+
* memory, forks with the environment: one lookup per script per branch. */
|
|
316
|
+
const NO_SOURCE_MAP = new Set();
|
|
317
|
+
/** Forget the branch memory (tests). */
|
|
318
|
+
export function resetNodeCoverageMemory() {
|
|
319
|
+
SHIPPED_V8_REPORTS.clear();
|
|
320
|
+
SHIPPED_SOURCE_MAPS.clear();
|
|
321
|
+
NO_SOURCE_MAP.clear();
|
|
322
|
+
}
|
|
323
|
+
/** Remove from `dir` the reports an earlier capture shipped. */
|
|
324
|
+
export async function removeShippedV8Reports(dir) {
|
|
402
325
|
const fs = await import("node:fs/promises");
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
326
|
+
for (const file of [...SHIPPED_V8_REPORTS]) {
|
|
327
|
+
if (!file.startsWith(dir + "/"))
|
|
328
|
+
continue;
|
|
329
|
+
await fs.unlink(file).catch(() => { });
|
|
330
|
+
SHIPPED_V8_REPORTS.delete(file);
|
|
408
331
|
}
|
|
409
332
|
}
|
|
410
|
-
/** V8 files already compacted, by path. Module memory: forks with the
|
|
411
|
-
* environment, so a child never re-parses its ancestors' dumps. */
|
|
412
|
-
const COMPACTED_V8_REPORTS = new Set();
|
|
413
333
|
/** A script is the app's own when it is a file outside node_modules —
|
|
414
334
|
* and not our hook, which every dump would otherwise carry. */
|
|
415
335
|
export function isAppScriptUrl(url) {
|
|
@@ -420,22 +340,34 @@ export function isAppScriptUrl(url) {
|
|
|
420
340
|
!url.includes("/.cache/") &&
|
|
421
341
|
!url.endsWith("/" + NODE_COVERAGE_HOOK_PATH.split("/").pop()));
|
|
422
342
|
}
|
|
343
|
+
/** True when any range of any function of the script has a count above
|
|
344
|
+
* zero: something in it ran since the previous take. A script whose
|
|
345
|
+
* every count is zero is one V8 still lists after a reset; it says
|
|
346
|
+
* nothing and is dropped. */
|
|
347
|
+
export function scriptExecuted(script) {
|
|
348
|
+
const fns = Array.isArray(script.functions) ? script.functions : [];
|
|
349
|
+
return fns.some((f) => Array.isArray(f.ranges) && f.ranges.some((r) => typeof r.count === "number" && r.count > 0));
|
|
350
|
+
}
|
|
423
351
|
/**
|
|
424
|
-
* Compact one V8 coverage document to the
|
|
425
|
-
* writes is everything the process loaded: `node:` internals, every
|
|
352
|
+
* Compact one V8 coverage document to what the derivation needs. What
|
|
353
|
+
* Node writes is everything the process loaded: `node:` internals, every
|
|
426
354
|
* `node_modules` file, and — under `--enable-source-maps` — a
|
|
427
355
|
* `source-map-cache` with each file's full map **and its sources**,
|
|
428
356
|
* repeated in every dump. Measured on a real project: a 12–20 MiB dump
|
|
429
357
|
* per capture, of which the app's own coverage was under 0.5 MiB, and a
|
|
430
358
|
* suite that hit the 64 MiB cap on its third test. Kept: `file://`
|
|
431
|
-
* scripts outside `node_modules
|
|
432
|
-
* scripts, minus `sourcesContent` (the sources
|
|
433
|
-
*
|
|
434
|
-
*
|
|
359
|
+
* scripts outside `node_modules` in which something ran, and the map
|
|
360
|
+
* entries of exactly those scripts, minus `sourcesContent` (the sources
|
|
361
|
+
* are the repo). Each kept map entry carries its content hash under
|
|
362
|
+
* {@link SOURCE_MAP_HASH_KEY}; when `shipped` holds the same hash for the
|
|
363
|
+
* URL — the map went out with an earlier dump on this branch — a stub
|
|
364
|
+
* `{ [SOURCE_MAP_REF_KEY]: hash }` stands in for it, and the reader finds
|
|
365
|
+
* the map in an earlier capture of the branch. `shipped` is updated in
|
|
366
|
+
* place. Still a V8 document — nothing is converted here.
|
|
435
367
|
*/
|
|
436
|
-
export function compactV8Document(doc,
|
|
368
|
+
export function compactV8Document(doc, shipped) {
|
|
437
369
|
const result = Array.isArray(doc.result) ? doc.result : [];
|
|
438
|
-
const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url));
|
|
370
|
+
const kept = result.filter((s) => typeof s.url === "string" && isAppScriptUrl(s.url) && scriptExecuted(s));
|
|
439
371
|
const out = { ...doc, result: kept };
|
|
440
372
|
const cache = doc["source-map-cache"];
|
|
441
373
|
if (cache && typeof cache === "object") {
|
|
@@ -445,11 +377,18 @@ export function compactV8Document(doc, opts = {}) {
|
|
|
445
377
|
if (!urls.has(url) || !entry || typeof entry !== "object")
|
|
446
378
|
continue;
|
|
447
379
|
const e = { ...entry };
|
|
448
|
-
if (
|
|
380
|
+
if (e.data && typeof e.data === "object") {
|
|
449
381
|
const { sourcesContent: _dropped, ...data } = e.data;
|
|
450
382
|
e.data = data;
|
|
451
383
|
}
|
|
452
|
-
|
|
384
|
+
const hash = sourceMapEntryHash(e);
|
|
385
|
+
if (shipped?.get(url) === hash) {
|
|
386
|
+
slim[url] = { [SOURCE_MAP_REF_KEY]: hash };
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
slim[url] = { ...e, [SOURCE_MAP_HASH_KEY]: hash };
|
|
390
|
+
shipped?.set(url, hash);
|
|
391
|
+
}
|
|
453
392
|
}
|
|
454
393
|
if (Object.keys(slim).length > 0)
|
|
455
394
|
out["source-map-cache"] = slim;
|
|
@@ -458,37 +397,207 @@ export function compactV8Document(doc, opts = {}) {
|
|
|
458
397
|
}
|
|
459
398
|
return out;
|
|
460
399
|
}
|
|
461
|
-
/**
|
|
462
|
-
|
|
400
|
+
/** The length of every line of `text`, the way Node computes it for
|
|
401
|
+
* `source-map-cache`: split on `\n` (and U+2028/2029), `\r` kept, the
|
|
402
|
+
* last line included. Lengths in UTF-16 units, which is what V8's byte
|
|
403
|
+
* offsets count in. */
|
|
404
|
+
export function lineLengthsOf(text) {
|
|
405
|
+
const out = [];
|
|
406
|
+
let len = 0;
|
|
407
|
+
for (let i = 0; i < text.length; i++, len++) {
|
|
408
|
+
const c = text.charCodeAt(i);
|
|
409
|
+
if (c === 0x0a || c === 0x2028 || c === 0x2029) {
|
|
410
|
+
out.push(len);
|
|
411
|
+
len = -1;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
out.push(len);
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
/** The last `sourceMappingURL` comment of a script, or `null`. */
|
|
418
|
+
export function sourceMappingUrlOf(script) {
|
|
419
|
+
const re = /\/\/[#@]\s*sourceMappingURL=(\S+)/g;
|
|
420
|
+
let last = null;
|
|
421
|
+
let m;
|
|
422
|
+
while ((m = re.exec(script)) !== null)
|
|
423
|
+
last = m[1];
|
|
424
|
+
return last;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* The source map of `scriptUrl`, read out of the container: the script
|
|
428
|
+
* itself (for its `sourceMappingURL` and its line lengths), then the map
|
|
429
|
+
* — inline as a `data:` URL, a file the comment names, or, with no
|
|
430
|
+
* comment, `<script>.map` next to it. `null` when the script is not a
|
|
431
|
+
* `file://` URL, cannot be read, or the map is not there or not JSON; a
|
|
432
|
+
* map's `sourcesContent` is dropped.
|
|
433
|
+
* One read per script per branch: the caller remembers the outcome.
|
|
434
|
+
*/
|
|
435
|
+
export async function resolveSourceMapFromContainer(ctx, scriptUrl) {
|
|
436
|
+
if (!ctx.readContainerFile || !scriptUrl.startsWith("file://"))
|
|
437
|
+
return null;
|
|
438
|
+
const { fileURLToPath, pathToFileURL } = await import("node:url");
|
|
439
|
+
const path = await import("node:path");
|
|
440
|
+
let scriptPath;
|
|
441
|
+
try {
|
|
442
|
+
scriptPath = fileURLToPath(scriptUrl);
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
const script = await ctx.readContainerFile(scriptPath);
|
|
448
|
+
if (!script)
|
|
449
|
+
return null;
|
|
450
|
+
const text = script.toString("utf8");
|
|
451
|
+
// No comment: the map next to the script, by convention (`x.js.map`).
|
|
452
|
+
// That is the shape to build a short-lived bundled process in: Node
|
|
453
|
+
// caches a map it finds through a comment into every dump it writes
|
|
454
|
+
// under NODE_V8_COVERAGE, flag or no flag, and a bundled CLI's map is
|
|
455
|
+
// tens of MB per exit; a map it does not find costs nothing there and
|
|
456
|
+
// is read here once per branch.
|
|
457
|
+
const ref = sourceMappingUrlOf(text) ?? `${path.basename(scriptPath)}.map`;
|
|
458
|
+
let mapText;
|
|
459
|
+
let mapUrl;
|
|
460
|
+
if (ref.startsWith("data:")) {
|
|
461
|
+
const comma = ref.indexOf(",");
|
|
462
|
+
if (comma < 0)
|
|
463
|
+
return null;
|
|
464
|
+
const head = ref.slice(5, comma);
|
|
465
|
+
const body = ref.slice(comma + 1);
|
|
466
|
+
try {
|
|
467
|
+
mapText = /;base64$/i.test(head) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body);
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
mapUrl = null;
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
let mapPath;
|
|
476
|
+
if (ref.startsWith("file://")) {
|
|
477
|
+
try {
|
|
478
|
+
mapPath = fileURLToPath(ref);
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
else if (/^[a-z]+:/i.test(ref)) {
|
|
485
|
+
return null; // an http(s) map is not in the container
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
mapPath = path.resolve(path.dirname(scriptPath), decodeURIComponent(ref));
|
|
489
|
+
}
|
|
490
|
+
const map = await ctx.readContainerFile(mapPath);
|
|
491
|
+
if (!map)
|
|
492
|
+
return null;
|
|
493
|
+
mapText = map.toString("utf8");
|
|
494
|
+
mapUrl = pathToFileURL(mapPath).href;
|
|
495
|
+
}
|
|
496
|
+
let data;
|
|
497
|
+
try {
|
|
498
|
+
data = JSON.parse(mapText);
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
if (!data || typeof data !== "object" || !("mappings" in data))
|
|
504
|
+
return null;
|
|
505
|
+
const { sourcesContent: _dropped, ...slim } = data;
|
|
506
|
+
return { url: mapUrl, data: slim, lineLengths: lineLengthsOf(text) };
|
|
507
|
+
}
|
|
508
|
+
/** The content hash of a map entry, over its JSON with the spectest
|
|
509
|
+
* keys removed. */
|
|
510
|
+
function sourceMapEntryHash(entry) {
|
|
511
|
+
const e = { ...entry };
|
|
512
|
+
delete e[SOURCE_MAP_HASH_KEY];
|
|
513
|
+
delete e[SOURCE_MAP_REF_KEY];
|
|
514
|
+
return "sha256:" + createHash("sha256").update(JSON.stringify(e)).digest("hex");
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Give every kept script of a compacted document a `source-map-cache`
|
|
518
|
+
* entry it lacks: a stub when the map shipped earlier on this branch,
|
|
519
|
+
* else the map `resolve` finds (shipped whole, with its hash, and
|
|
520
|
+
* remembered), else nothing — and that outcome is remembered too, so a
|
|
521
|
+
* script with no map is looked up once per branch. Returns how many maps
|
|
522
|
+
* `resolve` supplied.
|
|
523
|
+
*/
|
|
524
|
+
export async function attachSourceMaps(doc, resolve, shipped = SHIPPED_SOURCE_MAPS, missing = NO_SOURCE_MAP) {
|
|
525
|
+
const result = Array.isArray(doc.result) ? doc.result : [];
|
|
526
|
+
const cache = (doc["source-map-cache"] ?? {});
|
|
527
|
+
let found = 0;
|
|
528
|
+
for (const s of result) {
|
|
529
|
+
const url = s.url;
|
|
530
|
+
if (typeof url !== "string" || url in cache)
|
|
531
|
+
continue;
|
|
532
|
+
const prior = shipped.get(url);
|
|
533
|
+
if (prior) {
|
|
534
|
+
cache[url] = { [SOURCE_MAP_REF_KEY]: prior };
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (missing.has(url))
|
|
538
|
+
continue;
|
|
539
|
+
const entry = await resolve(url);
|
|
540
|
+
if (!entry) {
|
|
541
|
+
missing.add(url);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const hash = sourceMapEntryHash(entry);
|
|
545
|
+
cache[url] = { ...entry, [SOURCE_MAP_HASH_KEY]: hash };
|
|
546
|
+
shipped.set(url, hash);
|
|
547
|
+
found++;
|
|
548
|
+
}
|
|
549
|
+
if (Object.keys(cache).length > 0)
|
|
550
|
+
doc["source-map-cache"] = cache;
|
|
551
|
+
return found;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Compact, in place, every `coverage-*.json` in `dir` that no earlier
|
|
555
|
+
* capture shipped, attach the maps the dump lacks through `resolve`
|
|
556
|
+
* ({@link attachSourceMaps}), and mark it shipped. A dump mid-write (not
|
|
557
|
+
* yet valid JSON) is left for the next capture.
|
|
558
|
+
*/
|
|
559
|
+
export async function prepareV8Reports(dir, resolve) {
|
|
463
560
|
const fs = await import("node:fs/promises");
|
|
464
561
|
const path = await import("node:path");
|
|
562
|
+
const stats = { dumps: 0, scripts: 0, bytesIn: 0, bytesOut: 0, mapsFromContainer: 0 };
|
|
465
563
|
let names;
|
|
466
564
|
try {
|
|
467
565
|
names = await fs.readdir(dir);
|
|
468
566
|
}
|
|
469
567
|
catch {
|
|
470
|
-
return;
|
|
568
|
+
return stats;
|
|
471
569
|
}
|
|
472
|
-
for (const name of names) {
|
|
473
|
-
if (!name.startsWith("coverage-") || !name.endsWith(".json"))
|
|
570
|
+
for (const name of names.sort()) {
|
|
571
|
+
if (!name.startsWith("coverage-") || !name.endsWith(".json") || name === NODE_COVERAGE_EMPTY_REPORT)
|
|
474
572
|
continue;
|
|
475
573
|
const file = path.join(dir, name);
|
|
476
|
-
if (
|
|
574
|
+
if (SHIPPED_V8_REPORTS.has(file))
|
|
477
575
|
continue;
|
|
576
|
+
let text;
|
|
478
577
|
let doc;
|
|
479
578
|
try {
|
|
480
|
-
|
|
579
|
+
text = await fs.readFile(file, "utf8");
|
|
580
|
+
doc = JSON.parse(text);
|
|
481
581
|
}
|
|
482
582
|
catch {
|
|
483
583
|
continue; // a dump mid-write, or not ours; the read path judges it
|
|
484
584
|
}
|
|
485
585
|
if (!Array.isArray(doc.result))
|
|
486
586
|
continue;
|
|
587
|
+
const compact = compactV8Document(doc, SHIPPED_SOURCE_MAPS);
|
|
588
|
+
if (resolve)
|
|
589
|
+
stats.mapsFromContainer += await attachSourceMaps(compact, resolve);
|
|
590
|
+
const out = JSON.stringify(compact);
|
|
487
591
|
const tmp = path.join(dir, `.${name}.compact`);
|
|
488
|
-
await fs.writeFile(tmp,
|
|
592
|
+
await fs.writeFile(tmp, out);
|
|
489
593
|
await fs.rename(tmp, file);
|
|
490
|
-
|
|
594
|
+
SHIPPED_V8_REPORTS.add(file);
|
|
595
|
+
stats.dumps++;
|
|
596
|
+
stats.scripts += doc.result.length;
|
|
597
|
+
stats.bytesIn += text.length;
|
|
598
|
+
stats.bytesOut += out.length;
|
|
491
599
|
}
|
|
600
|
+
return stats;
|
|
492
601
|
}
|
|
493
602
|
// ── browser ───────────────────────────────────────────────────────
|
|
494
603
|
/**
|
package/dist/daemon.js
CHANGED
|
@@ -24,7 +24,7 @@ import { pathToFileURL } from "node:url";
|
|
|
24
24
|
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, validateServiceImage, proxy as makeProxyDecl, } from "./index.js";
|
|
25
25
|
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, } from "./harness/coverage.js";
|
|
26
26
|
import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
27
|
-
import { applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage,
|
|
27
|
+
import { applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, } from "./coverage.js";
|
|
28
28
|
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
29
29
|
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
30
30
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
@@ -40,7 +40,7 @@ import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta
|
|
|
40
40
|
import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
|
|
41
41
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
42
42
|
import { runWrapperRules } from "./harness/wrapper-rules.js";
|
|
43
|
-
import { cpus } from "node:os";
|
|
43
|
+
import { cpus, tmpdir } from "node:os";
|
|
44
44
|
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
|
|
45
45
|
import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
|
|
46
46
|
import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
|
|
@@ -690,16 +690,9 @@ async function ensureCoverage(svc) {
|
|
|
690
690
|
const host = coverageHostDir(WORKSPACE, svc.name);
|
|
691
691
|
await fs.mkdir(host, { recursive: true });
|
|
692
692
|
await fs.chmod(host, 0o777);
|
|
693
|
-
|
|
694
|
-
//
|
|
695
|
-
|
|
696
|
-
// — so the VM and the container agree on whether they are there. A
|
|
697
|
-
// plain `--volume`, never a config `volumes` entry: an absolute-source
|
|
698
|
-
// volume dir is listed in the delta-restore manifest and wiped.
|
|
699
|
-
if (await nodeCoverageToolsAvailable()) {
|
|
700
|
-
flags.push(`--volume=${nodeCoverageToolsDir()}:${NODE_COVERAGE_TOOLS_CONTAINER_DIR}:ro`);
|
|
701
|
-
}
|
|
702
|
-
return flags;
|
|
693
|
+
// Nothing else is mounted: no conversion runs in the container (the
|
|
694
|
+
// c8 tools mount went with SDK 0.80).
|
|
695
|
+
return [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
|
|
703
696
|
}
|
|
704
697
|
/** Keyed by image ID, not tag: `spectest/<svc>:latest` is retagged onto
|
|
705
698
|
* new content every rebuild, and a stale uid is silently wrong. */
|
|
@@ -3399,6 +3392,24 @@ function coverageCaptureContext(svc, signal, written) {
|
|
|
3399
3392
|
await fs.rename(tmp, file);
|
|
3400
3393
|
written.add(path.relative(reportDir, file));
|
|
3401
3394
|
},
|
|
3395
|
+
async readContainerFile(containerPath) {
|
|
3396
|
+
// `docker cp` rather than `exec cat`: a source map can be tens of
|
|
3397
|
+
// MB, and the exec path buffers stdout as a string. The copy lands
|
|
3398
|
+
// in the VM's tmp, never in the coverage directory (it would ship).
|
|
3399
|
+
const tmp = path.join(tmpdir(), `spectest-cov-${svc.name}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
3400
|
+
try {
|
|
3401
|
+
const r = await docker(["cp", `${svc.name}:${containerPath}`, tmp], COVERAGE_CAPTURE_TIMEOUT_MS);
|
|
3402
|
+
if (r.code !== 0)
|
|
3403
|
+
return null;
|
|
3404
|
+
return await fs.readFile(tmp);
|
|
3405
|
+
}
|
|
3406
|
+
catch {
|
|
3407
|
+
return null;
|
|
3408
|
+
}
|
|
3409
|
+
finally {
|
|
3410
|
+
await fs.rm(tmp, { force: true, recursive: true }).catch(() => { });
|
|
3411
|
+
}
|
|
3412
|
+
},
|
|
3402
3413
|
};
|
|
3403
3414
|
}
|
|
3404
3415
|
/** Run one service's adapters in order under one budget. Returns the
|
|
@@ -5055,8 +5066,7 @@ async function evalCode(code, secrets) {
|
|
|
5055
5066
|
// ────────────────────────────────────────────────────────────────────────
|
|
5056
5067
|
/** Where the baked compiler lives. Overridable so the typecheck — and the
|
|
5057
5068
|
* blocking rules, which always use THIS copy rather than the project's own —
|
|
5058
|
-
* can be driven outside a VM
|
|
5059
|
-
* `SPECTEST_COVERAGE_TOOLS_DIR`). */
|
|
5069
|
+
* can be driven outside a VM. */
|
|
5060
5070
|
const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
|
|
5061
5071
|
/** Cap on errors shipped in the report; `totalErrors` carries the true count. */
|
|
5062
5072
|
const TYPECHECK_ERROR_CAP = 50;
|