@specific.dev/spectest 0.119.0 → 0.119.2
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 -1
- package/dist/coverage.js +99 -29
- package/dist/daemon.js +8 -2
- package/dist/harness/browser-coverage.d.ts +3 -3
- package/dist/harness/browser-coverage.js +52 -4
- package/dist/harness/coverage.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/project-files.d.ts +3 -1
- package/dist/project-files.js +2 -2
- package/package.json +1 -1
- package/src/coverage.test.ts +78 -0
- package/src/coverage.ts +112 -30
- package/src/daemon.ts +8 -2
- package/src/harness/browser-coverage.test.ts +17 -2
- package/src/harness/browser-coverage.ts +38 -4
- package/src/harness/coverage.ts +2 -0
- package/src/index.ts +1 -1
- package/src/project-files.test.ts +25 -0
- package/src/project-files.ts +2 -2
package/dist/coverage.d.ts
CHANGED
|
@@ -127,6 +127,10 @@ export declare const INVENTORY_REPORT = "inventory.units";
|
|
|
127
127
|
export type ServiceCoverage = true | {
|
|
128
128
|
adapters?: readonly CoverageAdapter[];
|
|
129
129
|
reports?: CoverageReportsMode;
|
|
130
|
+
/** Repository-relative root of this service's build/bundler workspace.
|
|
131
|
+
* For a monorepo built from packages/, set sourceRoot: "packages".
|
|
132
|
+
* Paths are checked against the repository; no suffix guessing. */
|
|
133
|
+
sourceRoot?: string;
|
|
130
134
|
};
|
|
131
135
|
/** The `reports` mode of a `coverage` value, for the reports no adapter
|
|
132
136
|
* wrote (the program's own, a `command`'s). */
|
|
@@ -259,7 +263,13 @@ export declare function npmManifestUnit(projectPath: string, text: string): stri
|
|
|
259
263
|
* Prints one `npm:<name>@<version>` per line.
|
|
260
264
|
*/
|
|
261
265
|
export declare const NODE_INVENTORY_SCRIPT = "\"use strict\";\nconst fs = require(\"node:fs\"), path = require(\"node:path\");\nconst out = new Set();\nfunction isRealDir(p) { try { return fs.lstatSync(p).isDirectory(); } catch (e) { return false; } }\nfunction readPkg(d) {\n try {\n const j = JSON.parse(fs.readFileSync(path.join(d, \"package.json\"), \"utf8\"));\n if (j && typeof j.name === \"string\" && j.name) out.add(\"npm:\" + j.name + (typeof j.version === \"string\" && j.version ? \"@\" + j.version : \"\"));\n } catch (e) {}\n}\nfunction visit(d, depth) {\n readPkg(d);\n if (depth < 3) { const nested = path.join(d, \"node_modules\"); if (isRealDir(nested)) walkNm(nested, depth + 1); }\n}\nfunction walkNm(nm, depth) {\n let names; try { names = fs.readdirSync(nm); } catch (e) { return; }\n for (const n of names) {\n if (n === \".bin\" || n === \".cache\" || n === \".modules.yaml\") continue;\n if (n === \".pnpm\") {\n let ps; try { ps = fs.readdirSync(path.join(nm, \".pnpm\")); } catch (e) { continue; }\n for (const p of ps) { const inner = path.join(nm, \".pnpm\", p, \"node_modules\"); if (isRealDir(inner)) walkNm(inner, depth + 1); }\n continue;\n }\n if (n.startsWith(\"@\")) {\n let ss; try { ss = fs.readdirSync(path.join(nm, n)); } catch (e) { continue; }\n for (const s of ss) { const d = path.join(nm, n, s); if (isRealDir(d)) visit(d, depth); }\n } else { const d = path.join(nm, n); if (isRealDir(d)) visit(d, depth); }\n }\n}\nlet d = process.cwd();\nfor (;;) {\n const nm = path.join(d, \"node_modules\");\n if (isRealDir(nm)) walkNm(nm, 0);\n const up = path.dirname(d);\n if (up === d) break;\n d = up;\n}\nprocess.stdout.write(Array.from(out).sort().join(\"\\n\") + (out.size ? \"\\n\" : \"\"));\n";
|
|
262
|
-
export
|
|
266
|
+
export interface NodeCoverageOptions {
|
|
267
|
+
/** Generated runtime entrypoints, relative to the service working directory.
|
|
268
|
+
* Track their exact contents as units rather than inventing repository lines.
|
|
269
|
+
* A content change changes the inventory and invalidates dependent tests. */
|
|
270
|
+
generated?: readonly string[];
|
|
271
|
+
}
|
|
272
|
+
export declare function node(options?: NodeCoverageOptions): CoverageAdapter;
|
|
263
273
|
/**
|
|
264
274
|
* Ask every node process that holds a hook socket in `dir` for a dump.
|
|
265
275
|
* A socket nobody answers (its process died without unlinking — SIGKILL,
|
package/dist/coverage.js
CHANGED
|
@@ -57,14 +57,19 @@ export function validateCoverage(service, cov) {
|
|
|
57
57
|
const obj = typeof cov === "object" && cov !== null ? cov : null;
|
|
58
58
|
const adapters = obj?.adapters;
|
|
59
59
|
const reports = obj?.reports;
|
|
60
|
+
const sourceRoot = obj?.sourceRoot;
|
|
60
61
|
// `explains` is what `applyCoverageAdapters` adds for the wire; the
|
|
61
62
|
// config is validated again after that, so it must pass here.
|
|
62
63
|
const explains = obj?.explains;
|
|
63
|
-
const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports" || k === "explains");
|
|
64
|
+
const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports" || k === "explains" || k === "sourceRoot");
|
|
65
|
+
const okRoot = sourceRoot === undefined || (typeof sourceRoot === "string" &&
|
|
66
|
+
(sourceRoot === "." || (sourceRoot.length > 0 && !sourceRoot.includes("\\") &&
|
|
67
|
+
!sourceRoot.includes(":") && !sourceRoot.includes("\0") &&
|
|
68
|
+
sourceRoot.split("/").every(p => p !== "" && p !== "." && p !== ".."))));
|
|
64
69
|
const okReports = reports === undefined || reports === "delta" || reports === "cumulative";
|
|
65
70
|
const okAdapters = adapters === undefined || Array.isArray(adapters);
|
|
66
71
|
const okExplains = explains === undefined || (Array.isArray(explains) && explains.every((e) => typeof e === "string"));
|
|
67
|
-
if (obj !== null && knownKeys && okReports && okAdapters && okExplains && (adapters !== undefined || reports !== undefined)) {
|
|
72
|
+
if (obj !== null && knownKeys && okRoot && okReports && okAdapters && okExplains && (adapters !== undefined || reports !== undefined)) {
|
|
68
73
|
for (const a of adapters ?? []) {
|
|
69
74
|
const ok = typeof a === "object" &&
|
|
70
75
|
a !== null &&
|
|
@@ -111,7 +116,9 @@ export function applyCoverageAdapters(key, svc) {
|
|
|
111
116
|
// `explains` rides the wire config so the control plane's selector can
|
|
112
117
|
// read it without knowing any adapter.
|
|
113
118
|
const explains = [...new Set(adapters.flatMap((a) => a.explains ?? []))];
|
|
119
|
+
const sourceRoot = typeof svc.coverage === "object" ? svc.coverage.sourceRoot : undefined;
|
|
114
120
|
const wire = {
|
|
121
|
+
...(sourceRoot !== undefined ? { sourceRoot } : {}),
|
|
115
122
|
adapters: adapters.map((a) => withWireName(a)),
|
|
116
123
|
...(mode ? { reports: mode } : {}),
|
|
117
124
|
...(explains.length > 0 ? { explains } : {}),
|
|
@@ -241,6 +248,21 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
241
248
|
if (post("Profiler.enable") === null) return;
|
|
242
249
|
post("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
|
|
243
250
|
|
|
251
|
+
// Attach before app scripts execute. Enabling only at capture loses the
|
|
252
|
+
// source of collected vm contexts (e.g. Temporal workflow bundles).
|
|
253
|
+
// Bound V8's collected-script cache; live sources remain owned by V8.
|
|
254
|
+
post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
|
|
255
|
+
|
|
256
|
+
const GENERATED = new Map();
|
|
257
|
+
try {
|
|
258
|
+
for (const file of JSON.parse(process.env.SPECTEST_COVERAGE_GENERATED_FILES || "[]")) {
|
|
259
|
+
GENERATED.set(url.pathToFileURL(path.resolve(file)).href, file);
|
|
260
|
+
}
|
|
261
|
+
} catch (e) {}
|
|
262
|
+
const generatedUnits = new Map();
|
|
263
|
+
function generatedUnit(file, text) {
|
|
264
|
+
return "generated:" + process.env.SPECTEST_COVERAGE_SERVICE + ":" + file + "#" + crypto.createHash("sha256").update(text).digest("hex");
|
|
265
|
+
}
|
|
244
266
|
const SELF = url.pathToFileURL(__filename).href;
|
|
245
267
|
const SCRIPTS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SCRIPTS_SUBDIR)});
|
|
246
268
|
const MARKERS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_MARKERS_SUBDIR)});
|
|
@@ -347,31 +369,47 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
347
369
|
} catch (e) {
|
|
348
370
|
return null;
|
|
349
371
|
}
|
|
350
|
-
if (!data || typeof data !== "object" || !("mappings" in data)) return null;
|
|
351
|
-
delete data.sourcesContent;
|
|
352
372
|
// A source inside node_modules is a dependency: its unit id rides
|
|
353
373
|
// beside the map (\`sourceUnits[i]\`, null for the project's own), so
|
|
354
374
|
// the control plane never has to know what node_modules is.
|
|
355
375
|
const mapDir = mapUrl ? path.dirname(url.fileURLToPath(mapUrl)) : path.dirname(scriptPath);
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
376
|
+
function sourceUnits(data) {
|
|
377
|
+
const root = typeof data.sourceRoot === "string" ? data.sourceRoot : "";
|
|
378
|
+
return data.sources.map(function (src) {
|
|
379
|
+
if (typeof src !== "string") return null;
|
|
380
|
+
let abs;
|
|
381
|
+
if (src.startsWith("file://")) {
|
|
382
|
+
try {
|
|
383
|
+
abs = url.fileURLToPath(src);
|
|
384
|
+
} catch (e) {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
} else if (/^[a-z][a-z0-9+.-]*:/i.test(src)) {
|
|
388
|
+
return src.indexOf("/node_modules/") >= 0 ? unitFromText(src) : null;
|
|
389
|
+
} else {
|
|
390
|
+
const rel = root && !src.startsWith("/") ? root.replace(/\\/$/, "") + "/" + src : src;
|
|
391
|
+
abs = path.resolve(mapDir, rel);
|
|
365
392
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
393
|
+
return abs.indexOf("/node_modules/") >= 0 ? unitOf(abs) : null;
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
function prepareMap(data, depth) {
|
|
397
|
+
if (!data || typeof data !== "object" || depth > 32) return false;
|
|
398
|
+
delete data.sourcesContent;
|
|
399
|
+
if (Array.isArray(data.sections)) {
|
|
400
|
+
if ("mappings" in data) return false;
|
|
401
|
+
return data.sections.every(function (section) {
|
|
402
|
+
return section && !section.url && prepareMap(section.map, depth + 1);
|
|
403
|
+
});
|
|
371
404
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
405
|
+
if (!Array.isArray(data.sources) || typeof data.mappings !== "string") return false;
|
|
406
|
+
data.x_spectestSourceUnits = sourceUnits(data);
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
if (!prepareMap(data, 0)) return null;
|
|
410
|
+
const units = data.x_spectestSourceUnits;
|
|
411
|
+
delete data.x_spectestSourceUnits;
|
|
412
|
+
return { url: mapUrl, data: data, sourceUnits: units };
|
|
375
413
|
}
|
|
376
414
|
function writeAtomic(file, text) {
|
|
377
415
|
const tmp = file + "." + process.pid + "-" + wt.threadId + ".tmp";
|
|
@@ -420,34 +458,44 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
420
458
|
if (kept.length === 0 && units.size === 0) return;
|
|
421
459
|
const fresh = [];
|
|
422
460
|
for (const s of kept) {
|
|
423
|
-
if (!known.has(s.scriptId)) fresh.push(s);
|
|
461
|
+
if (!known.has(s.scriptId) && !generatedUnits.has(s.scriptId)) fresh.push(s);
|
|
424
462
|
}
|
|
425
463
|
if (fresh.length > 0) {
|
|
426
464
|
try {
|
|
427
465
|
fs.mkdirSync(SCRIPTS, { recursive: true });
|
|
428
466
|
fs.mkdirSync(MARKERS, { recursive: true });
|
|
429
467
|
} catch (e) {}
|
|
430
|
-
post("Debugger.enable");
|
|
431
468
|
for (const s of fresh) {
|
|
432
469
|
const r = post("Debugger.getScriptSource", { scriptId: s.scriptId });
|
|
433
470
|
try {
|
|
434
|
-
|
|
471
|
+
const source = r && r.scriptSource;
|
|
472
|
+
if (GENERATED.has(s.url) && typeof source === "string") {
|
|
473
|
+
generatedUnits.set(s.scriptId, generatedUnit(GENERATED.get(s.url), source));
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const id = describe(s, source);
|
|
477
|
+
// A failed source fetch remains visible in this dump, but is not
|
|
478
|
+
// memoized as a successful descriptor for subsequent captures.
|
|
479
|
+
if (typeof source === "string") known.set(s.scriptId, id);
|
|
480
|
+
s.missingSourceRecord = id;
|
|
435
481
|
} catch (e) {}
|
|
436
482
|
}
|
|
437
|
-
post("Debugger.disable");
|
|
438
483
|
}
|
|
439
484
|
// A ran function is one flat array: [start, length, count] for its
|
|
440
485
|
// extent (V8's first range), then [offset from start, length, count]
|
|
441
486
|
// per block range in V8's order. Small numbers, so the dump gzips to
|
|
442
487
|
// less than half of V8's own JSON; name and block flag live in the
|
|
443
488
|
// script record, joined on the extent.
|
|
489
|
+
for (const s of kept) {
|
|
490
|
+
if (generatedUnits.has(s.scriptId)) units.add(generatedUnits.get(s.scriptId));
|
|
491
|
+
}
|
|
444
492
|
const doc = {
|
|
445
493
|
spectestVersion: 2,
|
|
446
494
|
units: Array.from(units).sort(),
|
|
447
|
-
result: kept.map(function (s) {
|
|
495
|
+
result: kept.filter(function (s) { return !generatedUnits.has(s.scriptId); }).map(function (s) {
|
|
448
496
|
return {
|
|
449
497
|
url: s.url,
|
|
450
|
-
spectestScript: known.get(s.scriptId) || null,
|
|
498
|
+
spectestScript: known.get(s.scriptId) || s.missingSourceRecord || null,
|
|
451
499
|
functions: s.functions.filter(ran).map(function (f) {
|
|
452
500
|
const first = f.ranges[0];
|
|
453
501
|
const out = [first.startOffset, first.endOffset - first.startOffset, first.count];
|
|
@@ -625,7 +673,15 @@ for (;;) {
|
|
|
625
673
|
}
|
|
626
674
|
process.stdout.write(Array.from(out).sort().join("\\n") + (out.size ? "\\n" : ""));
|
|
627
675
|
`;
|
|
628
|
-
export function node() {
|
|
676
|
+
export function node(options = {}) {
|
|
677
|
+
if (options.generated !== undefined && (!Array.isArray(options.generated) || options.generated.some(p => typeof p !== "string"))) {
|
|
678
|
+
throw new Error("coverage.node generated must be a list of relative files");
|
|
679
|
+
}
|
|
680
|
+
const generated = [...new Set(options.generated ?? [])].sort();
|
|
681
|
+
if (generated.some(p => !p || p.includes("\\") || p.includes(":") || p.includes("\0") ||
|
|
682
|
+
p.split("/").some(c => !c || c === "." || c === ".."))) {
|
|
683
|
+
throw new Error("coverage.node generated paths must be relative files without traversal");
|
|
684
|
+
}
|
|
629
685
|
return {
|
|
630
686
|
name: "node",
|
|
631
687
|
reports: "delta",
|
|
@@ -637,6 +693,16 @@ export function node() {
|
|
|
637
693
|
const b64 = Buffer.from(NODE_INVENTORY_SCRIPT).toString("base64");
|
|
638
694
|
const r = await ctx.exec(`echo ${b64} | base64 -d | node -`);
|
|
639
695
|
const units = new Set(r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("npm:")));
|
|
696
|
+
if (generated.length) {
|
|
697
|
+
const script = `const fs=require("node:fs"),crypto=require("node:crypto");
|
|
698
|
+
for(const file of ${JSON.stringify(generated)}) console.log(
|
|
699
|
+
${JSON.stringify("generated:" + ctx.service + ":")} + file + "#" +
|
|
700
|
+
crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"));`;
|
|
701
|
+
const result = await ctx.exec(`echo ${Buffer.from(script).toString("base64")} | base64 -d | node -`);
|
|
702
|
+
for (const unit of result.stdout.trim().split("\n"))
|
|
703
|
+
if (unit)
|
|
704
|
+
units.add(unit);
|
|
705
|
+
}
|
|
640
706
|
for (const f of await ctx.projectFiles()) {
|
|
641
707
|
if (f !== "package.json" && !f.endsWith("/package.json"))
|
|
642
708
|
continue;
|
|
@@ -646,9 +712,13 @@ export function node() {
|
|
|
646
712
|
}
|
|
647
713
|
return [...units].sort();
|
|
648
714
|
},
|
|
649
|
-
configure(svc) {
|
|
715
|
+
configure(svc, info) {
|
|
650
716
|
const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
651
717
|
env[NODE_COVERAGE_DIR_ENV] = COVERAGE_CONTAINER_DIR;
|
|
718
|
+
if (generated.length) {
|
|
719
|
+
env.SPECTEST_COVERAGE_GENERATED_FILES = JSON.stringify(generated);
|
|
720
|
+
env.SPECTEST_COVERAGE_SERVICE = info.key;
|
|
721
|
+
}
|
|
652
722
|
return {
|
|
653
723
|
...svc,
|
|
654
724
|
env,
|
package/dist/daemon.js
CHANGED
|
@@ -1003,7 +1003,7 @@ async function resolveDockerfileBuild(svc) {
|
|
|
1003
1003
|
throw new Error(`service "${svc.name}" image is not a Dockerfile build`);
|
|
1004
1004
|
}
|
|
1005
1005
|
const contextRel = image.context ?? ".";
|
|
1006
|
-
const contextDir = resolveExistingProjectPath(contextRel, `service "${svc.name}" build context
|
|
1006
|
+
const contextDir = resolveExistingProjectPath(contextRel, `service "${svc.name}" build context`, { record: false });
|
|
1007
1007
|
let content;
|
|
1008
1008
|
let adjacent = null;
|
|
1009
1009
|
let adjacentPath = null;
|
|
@@ -1214,7 +1214,10 @@ async function runServiceBuild(name, image, tag) {
|
|
|
1214
1214
|
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, contextDir];
|
|
1215
1215
|
}
|
|
1216
1216
|
progressService(name, { status: "building", detail: "starting build" });
|
|
1217
|
-
|
|
1217
|
+
// The controller owns the bootstrap/prepareImages deadline and tears
|
|
1218
|
+
// down the VM on expiry. A separate build timer would cut short an
|
|
1219
|
+
// environment's explicit timeoutSecs allowance (e.g. long Rust builds).
|
|
1220
|
+
const build = await shxStream("docker", buildArgs, undefined, buildEnv, (line) => {
|
|
1218
1221
|
// BuildKit `--progress=plain` declares each step as
|
|
1219
1222
|
// `#N [<stage> M/N] <cmd>`; the legacy builder as `Step M/N : <cmd>`.
|
|
1220
1223
|
// Track the most-recent step as live detail.
|
|
@@ -3436,6 +3439,9 @@ async function captureServiceCoverage(baseline = false) {
|
|
|
3436
3439
|
const root = await coverageRoot(svc.name);
|
|
3437
3440
|
if (root)
|
|
3438
3441
|
read.root = root;
|
|
3442
|
+
if (typeof svc.coverage === "object" && svc.coverage.sourceRoot !== undefined) {
|
|
3443
|
+
read.sourceRoot = svc.coverage.sourceRoot;
|
|
3444
|
+
}
|
|
3439
3445
|
// What ships is what ran during this capture, whatever the tool
|
|
3440
3446
|
// counts: cumulative lcov is subtracted against the previous
|
|
3441
3447
|
// capture (module memory, forks with the env); a delta adapter's
|
|
@@ -61,12 +61,12 @@ export declare function isForeignSource(path: string): boolean;
|
|
|
61
61
|
*/
|
|
62
62
|
export declare function isDocumentScriptPath(pathname: string): boolean;
|
|
63
63
|
/**
|
|
64
|
-
* Decode
|
|
65
|
-
*
|
|
64
|
+
* Decode flat and embedded index maps. Unsupported external section URLs
|
|
65
|
+
* and malformed maps decode to `null` — the
|
|
66
66
|
* caller then falls back to attributing the script to its own URL, which
|
|
67
67
|
* the repo mapping cannot resolve, which is the safe answer.
|
|
68
68
|
*/
|
|
69
|
-
export declare function decodeSourceMap(doc: unknown): DecodedSourceMap | null;
|
|
69
|
+
export declare function decodeSourceMap(doc: unknown, depth?: number): DecodedSourceMap | null;
|
|
70
70
|
/**
|
|
71
71
|
* Attribute generated line counts to original (file, line)s through the
|
|
72
72
|
* map. Every original line a generated line's segments name gets that
|
|
@@ -83,6 +83,8 @@ export function decodeVlq(segment) {
|
|
|
83
83
|
shift = 0;
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
+
if (shift !== 0)
|
|
87
|
+
throw new Error("unterminated VLQ");
|
|
86
88
|
return out;
|
|
87
89
|
}
|
|
88
90
|
/**
|
|
@@ -121,6 +123,9 @@ export function normalizeSourcePath(src, sourceRoot) {
|
|
|
121
123
|
/** Sources nobody's change can be attributed to: dependencies and bundler
|
|
122
124
|
* runtime shims. */
|
|
123
125
|
export function isForeignSource(path) {
|
|
126
|
+
// Preserve project namespaces; only the explicit Turbopack runtime is foreign.
|
|
127
|
+
if (path.startsWith("turbopack:///[turbopack]/"))
|
|
128
|
+
return true;
|
|
124
129
|
return (path.length === 0 ||
|
|
125
130
|
path.includes("node_modules/") ||
|
|
126
131
|
path.startsWith("webpack/") ||
|
|
@@ -143,16 +148,53 @@ export function isDocumentScriptPath(pathname) {
|
|
|
143
148
|
return !last.includes(".");
|
|
144
149
|
}
|
|
145
150
|
/**
|
|
146
|
-
* Decode
|
|
147
|
-
*
|
|
151
|
+
* Decode flat and embedded index maps. Unsupported external section URLs
|
|
152
|
+
* and malformed maps decode to `null` — the
|
|
148
153
|
* caller then falls back to attributing the script to its own URL, which
|
|
149
154
|
* the repo mapping cannot resolve, which is the safe answer.
|
|
150
155
|
*/
|
|
151
|
-
export function decodeSourceMap(doc) {
|
|
156
|
+
export function decodeSourceMap(doc, depth = 0) {
|
|
157
|
+
if (depth > 32)
|
|
158
|
+
return null;
|
|
152
159
|
if (!doc || typeof doc !== "object")
|
|
153
160
|
return null;
|
|
154
161
|
const m = doc;
|
|
155
|
-
if (m.sections
|
|
162
|
+
if (m.sections !== undefined) {
|
|
163
|
+
if (!Array.isArray(m.sections) || m.mappings !== undefined)
|
|
164
|
+
return null;
|
|
165
|
+
const out = { sources: [], lines: [] };
|
|
166
|
+
let previous;
|
|
167
|
+
for (const section of m.sections) {
|
|
168
|
+
const { line, column } = section?.offset ?? {};
|
|
169
|
+
if (section?.url !== undefined || !Number.isSafeInteger(line) || line < 0 ||
|
|
170
|
+
!Number.isSafeInteger(column) || column < 0 || line > 1_000_000 ||
|
|
171
|
+
(previous && (line < previous[0] || (line === previous[0] && column <= previous[1]))))
|
|
172
|
+
return null;
|
|
173
|
+
previous = [line, column];
|
|
174
|
+
const child = decodeSourceMap(section.map, depth + 1);
|
|
175
|
+
if (!child)
|
|
176
|
+
return null;
|
|
177
|
+
if (out.lines.some((segments, i) => i > line && segments.length > 0 ||
|
|
178
|
+
i === line && segments.some(s => s.col >= column)))
|
|
179
|
+
return null;
|
|
180
|
+
const base = out.sources.length;
|
|
181
|
+
for (const source of child.sources)
|
|
182
|
+
out.sources.push(source);
|
|
183
|
+
for (let i = 0; i < child.lines.length; i++) {
|
|
184
|
+
const target = line + i;
|
|
185
|
+
if (target > 1_000_000)
|
|
186
|
+
return null;
|
|
187
|
+
while (out.lines.length <= target)
|
|
188
|
+
out.lines.push([]);
|
|
189
|
+
for (const seg of child.lines[i])
|
|
190
|
+
out.lines[target].push({
|
|
191
|
+
col: seg.col + (i === 0 ? column : 0), src: seg.src + base, line: seg.line,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
if (!Array.isArray(m.sources) || typeof m.mappings !== "string") {
|
|
156
198
|
return null;
|
|
157
199
|
}
|
|
158
200
|
const sourceRoot = typeof m.sourceRoot === "string" ? m.sourceRoot : undefined;
|
|
@@ -169,10 +211,16 @@ export function decodeSourceMap(doc) {
|
|
|
169
211
|
if (seg.length === 0)
|
|
170
212
|
continue;
|
|
171
213
|
const f = decodeVlq(seg);
|
|
214
|
+
if (![1, 4, 5].includes(f.length))
|
|
215
|
+
return null;
|
|
172
216
|
col += f[0];
|
|
217
|
+
if (!Number.isSafeInteger(col) || col < 0)
|
|
218
|
+
return null;
|
|
173
219
|
if (f.length >= 4) {
|
|
174
220
|
src += f[1];
|
|
175
221
|
line += f[2];
|
|
222
|
+
if (src < 0 || src >= sources.length || !Number.isSafeInteger(line) || line < 0)
|
|
223
|
+
return null;
|
|
176
224
|
segs.push({ col, src, line });
|
|
177
225
|
}
|
|
178
226
|
}
|
|
@@ -32,6 +32,8 @@ export interface ServiceCoverageCapture {
|
|
|
32
32
|
* never has to fetch code to do so. Absent for a container it could
|
|
33
33
|
* not be read from. */
|
|
34
34
|
root?: string;
|
|
35
|
+
/** Repository root of the service build/bundler workspace, when explicit. */
|
|
36
|
+
sourceRoot?: string;
|
|
35
37
|
/** Set when the capture failed: the command failed, the directory was
|
|
36
38
|
* empty, no file held an `SF:` record, or the reports were too large.
|
|
37
39
|
* A case with an error here fails — a hole in the coverage data must
|
package/dist/index.js
CHANGED
|
@@ -296,7 +296,7 @@ export function validateServiceImage(serviceName, input) {
|
|
|
296
296
|
if (typeof image.context !== "string" || image.context === "") {
|
|
297
297
|
throw new Error(`service "${serviceName}" image context must be a directory path relative to the project root (\`"."\` for the root)`);
|
|
298
298
|
}
|
|
299
|
-
const dir = resolveExistingProjectPath(image.context, `service "${serviceName}" build context
|
|
299
|
+
const dir = resolveExistingProjectPath(image.context, `service "${serviceName}" build context`, { record: false });
|
|
300
300
|
if (!statSync(dir).isDirectory()) {
|
|
301
301
|
throw new Error(`service "${serviceName}" build context ${JSON.stringify(image.context)} is not a directory`);
|
|
302
302
|
}
|
package/dist/project-files.d.ts
CHANGED
|
@@ -20,4 +20,6 @@ export declare function resolveProjectPath(p: string, opts?: {
|
|
|
20
20
|
/** {@link resolveProjectPath}, but a missing file is an error that names every
|
|
21
21
|
* place we looked. For inputs a user hands us by name — a fixture — where an
|
|
22
22
|
* ENOENT on one guessed path reads as a spectest bug rather than a typo. */
|
|
23
|
-
export declare function resolveExistingProjectPath(p: string, what?: string
|
|
23
|
+
export declare function resolveExistingProjectPath(p: string, what?: string, opts?: {
|
|
24
|
+
record?: boolean;
|
|
25
|
+
}): string;
|
package/dist/project-files.js
CHANGED
|
@@ -68,8 +68,8 @@ export function resolveProjectPath(p, opts = {}) {
|
|
|
68
68
|
/** {@link resolveProjectPath}, but a missing file is an error that names every
|
|
69
69
|
* place we looked. For inputs a user hands us by name — a fixture — where an
|
|
70
70
|
* ENOENT on one guessed path reads as a spectest bug rather than a typo. */
|
|
71
|
-
export function resolveExistingProjectPath(p, what = "file") {
|
|
72
|
-
const resolved = resolveProjectPath(p);
|
|
71
|
+
export function resolveExistingProjectPath(p, what = "file", opts = {}) {
|
|
72
|
+
const resolved = resolveProjectPath(p, opts);
|
|
73
73
|
if (existsSync(resolved))
|
|
74
74
|
return resolved;
|
|
75
75
|
const rel = relative(p);
|
package/package.json
CHANGED
package/src/coverage.test.ts
CHANGED
|
@@ -51,6 +51,14 @@ describe("validateCoverage", () => {
|
|
|
51
51
|
expect(() => validateCoverage("a", { adapters: [], extra: 1 })).toThrow(/invalid `coverage` value/);
|
|
52
52
|
expect(() => validateCoverage("a", {})).toThrow(/invalid `coverage` value/);
|
|
53
53
|
});
|
|
54
|
+
test("sourceRoot is explicit, validated, and survives adapter wire serialization", () => {
|
|
55
|
+
const out = applyCoverageAdapters("web", { ...base, coverage: { adapters: [node(), browser()], sourceRoot: "packages" } });
|
|
56
|
+
expect(JSON.parse(JSON.stringify(out)).coverage.sourceRoot).toBe("packages");
|
|
57
|
+
expect(() => validateCoverage("web", out.coverage)).not.toThrow();
|
|
58
|
+
for (const sourceRoot of ["", "/packages", "../packages", "packages//dashboard", "packages/./dashboard", "C:\\app", "bad\0root"]) {
|
|
59
|
+
expect(() => validateCoverage("web", { adapters: [node()], sourceRoot })).toThrow();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
54
62
|
test("reports mode: cumulative unless declared; the shipped adapters write deltas", () => {
|
|
55
63
|
expect(coverageReportsMode(undefined)).toBe("cumulative");
|
|
56
64
|
expect(coverageReportsMode(true)).toBe("cumulative");
|
|
@@ -375,6 +383,76 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
375
383
|
expect(new Set(all.map((s) => s.spectestScript))).toEqual(ids);
|
|
376
384
|
});
|
|
377
385
|
|
|
386
|
+
test("declared generated entrypoints are content-addressed units in capture and inventory", async () => {
|
|
387
|
+
const dir = await scratch();
|
|
388
|
+
const adapter = node({ generated: ["server.js"] });
|
|
389
|
+
const config = adapter.configure!({ ...base, env: {} }, {key: "web"});
|
|
390
|
+
const env = {...hookEnv(dir), SPECTEST_COVERAGE_SERVICE: config.env!.SPECTEST_COVERAGE_SERVICE!,
|
|
391
|
+
SPECTEST_COVERAGE_GENERATED_FILES: config.env!.SPECTEST_COVERAGE_GENERATED_FILES!};
|
|
392
|
+
async function inventory() {
|
|
393
|
+
return adapter.inventory!({ service: "web", projectFiles: async () => [], readProjectFile: async () => null,
|
|
394
|
+
signal: new AbortController().signal,
|
|
395
|
+
async exec(command) {
|
|
396
|
+
const proc = Bun.spawn(["sh", "-c", command], {cwd:dir, stdout:"pipe", stderr:"pipe"});
|
|
397
|
+
const stdout = await new Response(proc.stdout).text();
|
|
398
|
+
const stderr = await new Response(proc.stderr).text();
|
|
399
|
+
if (await proc.exited !== 0) throw new Error(stderr);
|
|
400
|
+
return {stdout,stderr};
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
async function run() {
|
|
405
|
+
const child = spawn("node", [path.join(dir,"server.js")], {cwd:dir,env,stdio:"ignore"});
|
|
406
|
+
expect(await new Promise(r => child.on("exit",r))).toBe(0);
|
|
407
|
+
}
|
|
408
|
+
await fs.writeFile(path.join(dir,"server.js"), "globalThis.value = 1;\n");
|
|
409
|
+
const first = await inventory();
|
|
410
|
+
expect(first).toHaveLength(1);
|
|
411
|
+
expect(first[0]).toMatch(/^generated:web:server.js#[a-f0-9]{64}$/);
|
|
412
|
+
await run();
|
|
413
|
+
const dump = await readDump(dir,(await dumpsIn(dir))[0]!);
|
|
414
|
+
expect((dump as Dump & {units:string[]}).units).toEqual(first);
|
|
415
|
+
expect(dump.result).toEqual([]);
|
|
416
|
+
expect((await records(dir)).size).toBe(0);
|
|
417
|
+
await fs.writeFile(path.join(dir,"server.js"), "globalThis.value = 2;\n");
|
|
418
|
+
expect(await inventory()).not.toEqual(first);
|
|
419
|
+
expect(() => node({generated:["../server.js"]})).toThrow();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("collected VM contexts retain their compiled source with a bounded debugger cache", async () => {
|
|
423
|
+
const dir = await scratch();
|
|
424
|
+
await fs.writeFile(path.join(dir, "main.js"), `
|
|
425
|
+
const vm = require("node:vm");
|
|
426
|
+
new vm.Script("globalThis.value = 42; /*" + "x".repeat(8 * 1024 * 1024) + "*/", {
|
|
427
|
+
filename: "file:///tmp/workflow-bundle.js"
|
|
428
|
+
}).runInNewContext();
|
|
429
|
+
for (let i = 0; i < 3; i++) global.gc();
|
|
430
|
+
`);
|
|
431
|
+
const child = spawn("node", ["--expose-gc", path.join(dir, "main.js")], { env: hookEnv(dir), stdio: "ignore" });
|
|
432
|
+
expect(await new Promise(r => child.on("exit", r))).toBe(0);
|
|
433
|
+
const rec = (await records(dir)).get("file:///tmp/workflow-bundle.js")!;
|
|
434
|
+
expect(rec.lineLengths?.length).toBe(1);
|
|
435
|
+
expect(rec.lineLengths![0]).toBeGreaterThan(8 * 1024 * 1024);
|
|
436
|
+
expect(JSON.stringify(rec).length).toBeLessThan(1024);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("indexed maps retain offsets and dependency units and strip nested source contents", async () => {
|
|
440
|
+
const dir = await scratch();
|
|
441
|
+
await fs.writeFile(path.join(dir, "main.js"), "globalThis.value = 1;\n");
|
|
442
|
+
const leaf = { version: 3, sources: ["turbopack:///[project]/dashboard/page.ts", "webpack:///node_modules/pkg/a.js"],
|
|
443
|
+
sourcesContent: ["secret application source", "dependency source"], mappings: "AAAA,CCAA" };
|
|
444
|
+
await fs.writeFile(path.join(dir, "main.js.map"), JSON.stringify({ version: 3, sections: [
|
|
445
|
+
{ offset: { line: 0, column: 0 }, map: { version: 3, sections: [{ offset: { line: 0, column: 0 }, map: leaf }] } }
|
|
446
|
+
] }));
|
|
447
|
+
expect(await runOnce(dir, "main.js")).toBe(0);
|
|
448
|
+
const rec = (await records(dir)).get(`file://${path.join(dir, "main.js")}`)!;
|
|
449
|
+
const text = JSON.stringify(rec.sourceMap);
|
|
450
|
+
expect(text).not.toContain("sourcesContent");
|
|
451
|
+
expect(text).not.toContain("secret application source");
|
|
452
|
+
expect(text).toContain('"x_spectestSourceUnits":[null,"npm:pkg"]');
|
|
453
|
+
expect(text).toContain('"offset":{"line":0,"column":0}');
|
|
454
|
+
});
|
|
455
|
+
|
|
378
456
|
test("short-lived CLI processes write at exit; the map next to the script is recorded once, no NODE_V8_COVERAGE", async () => {
|
|
379
457
|
const dir = await scratch();
|
|
380
458
|
await fs.writeFile(path.join(dir, "cli.js"), "function used() { return 1 }\nfunction unused() { return 2 }\nused();\n");
|
package/src/coverage.ts
CHANGED
|
@@ -164,7 +164,14 @@ export const INVENTORY_REPORT = "inventory.units";
|
|
|
164
164
|
* program itself writes count (`reports`, default `"cumulative"`). */
|
|
165
165
|
export type ServiceCoverage =
|
|
166
166
|
| true
|
|
167
|
-
| {
|
|
167
|
+
| {
|
|
168
|
+
adapters?: readonly CoverageAdapter[];
|
|
169
|
+
reports?: CoverageReportsMode;
|
|
170
|
+
/** Repository-relative root of this service's build/bundler workspace.
|
|
171
|
+
* For a monorepo built from packages/, set sourceRoot: "packages".
|
|
172
|
+
* Paths are checked against the repository; no suffix guessing. */
|
|
173
|
+
sourceRoot?: string;
|
|
174
|
+
};
|
|
168
175
|
|
|
169
176
|
/** The `reports` mode of a `coverage` value, for the reports no adapter
|
|
170
177
|
* wrote (the program's own, a `command`'s). */
|
|
@@ -184,14 +191,19 @@ export function validateCoverage(service: string, cov: unknown): void {
|
|
|
184
191
|
const obj = typeof cov === "object" && cov !== null ? (cov as Record<string, unknown>) : null;
|
|
185
192
|
const adapters = obj?.adapters;
|
|
186
193
|
const reports = obj?.reports;
|
|
194
|
+
const sourceRoot = obj?.sourceRoot;
|
|
187
195
|
// `explains` is what `applyCoverageAdapters` adds for the wire; the
|
|
188
196
|
// config is validated again after that, so it must pass here.
|
|
189
197
|
const explains = obj?.explains;
|
|
190
|
-
const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports" || k === "explains");
|
|
198
|
+
const knownKeys = obj !== null && Object.keys(obj).every((k) => k === "adapters" || k === "reports" || k === "explains" || k === "sourceRoot");
|
|
199
|
+
const okRoot = sourceRoot === undefined || (typeof sourceRoot === "string" &&
|
|
200
|
+
(sourceRoot === "." || (sourceRoot.length > 0 && !sourceRoot.includes("\\") &&
|
|
201
|
+
!sourceRoot.includes(":") && !sourceRoot.includes("\0") &&
|
|
202
|
+
sourceRoot.split("/").every(p => p !== "" && p !== "." && p !== ".."))));
|
|
191
203
|
const okReports = reports === undefined || reports === "delta" || reports === "cumulative";
|
|
192
204
|
const okAdapters = adapters === undefined || Array.isArray(adapters);
|
|
193
205
|
const okExplains = explains === undefined || (Array.isArray(explains) && explains.every((e) => typeof e === "string"));
|
|
194
|
-
if (obj !== null && knownKeys && okReports && okAdapters && okExplains && (adapters !== undefined || reports !== undefined)) {
|
|
206
|
+
if (obj !== null && knownKeys && okRoot && okReports && okAdapters && okExplains && (adapters !== undefined || reports !== undefined)) {
|
|
195
207
|
for (const a of (adapters as unknown[] | undefined) ?? []) {
|
|
196
208
|
const ok =
|
|
197
209
|
typeof a === "object" &&
|
|
@@ -246,7 +258,9 @@ export function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc:
|
|
|
246
258
|
// `explains` rides the wire config so the control plane's selector can
|
|
247
259
|
// read it without knowing any adapter.
|
|
248
260
|
const explains = [...new Set(adapters.flatMap((a) => a.explains ?? []))];
|
|
261
|
+
const sourceRoot = typeof svc.coverage === "object" ? svc.coverage.sourceRoot : undefined;
|
|
249
262
|
const wire = {
|
|
263
|
+
...(sourceRoot !== undefined ? { sourceRoot } : {}),
|
|
250
264
|
adapters: adapters.map((a) => withWireName(a)),
|
|
251
265
|
...(mode ? { reports: mode } : {}),
|
|
252
266
|
...(explains.length > 0 ? { explains } : {}),
|
|
@@ -387,6 +401,21 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
387
401
|
if (post("Profiler.enable") === null) return;
|
|
388
402
|
post("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
|
|
389
403
|
|
|
404
|
+
// Attach before app scripts execute. Enabling only at capture loses the
|
|
405
|
+
// source of collected vm contexts (e.g. Temporal workflow bundles).
|
|
406
|
+
// Bound V8's collected-script cache; live sources remain owned by V8.
|
|
407
|
+
post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
|
|
408
|
+
|
|
409
|
+
const GENERATED = new Map();
|
|
410
|
+
try {
|
|
411
|
+
for (const file of JSON.parse(process.env.SPECTEST_COVERAGE_GENERATED_FILES || "[]")) {
|
|
412
|
+
GENERATED.set(url.pathToFileURL(path.resolve(file)).href, file);
|
|
413
|
+
}
|
|
414
|
+
} catch (e) {}
|
|
415
|
+
const generatedUnits = new Map();
|
|
416
|
+
function generatedUnit(file, text) {
|
|
417
|
+
return "generated:" + process.env.SPECTEST_COVERAGE_SERVICE + ":" + file + "#" + crypto.createHash("sha256").update(text).digest("hex");
|
|
418
|
+
}
|
|
390
419
|
const SELF = url.pathToFileURL(__filename).href;
|
|
391
420
|
const SCRIPTS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SCRIPTS_SUBDIR)});
|
|
392
421
|
const MARKERS = path.join(dir, ${JSON.stringify(NODE_COVERAGE_MARKERS_SUBDIR)});
|
|
@@ -493,31 +522,47 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
493
522
|
} catch (e) {
|
|
494
523
|
return null;
|
|
495
524
|
}
|
|
496
|
-
if (!data || typeof data !== "object" || !("mappings" in data)) return null;
|
|
497
|
-
delete data.sourcesContent;
|
|
498
525
|
// A source inside node_modules is a dependency: its unit id rides
|
|
499
526
|
// beside the map (\`sourceUnits[i]\`, null for the project's own), so
|
|
500
527
|
// the control plane never has to know what node_modules is.
|
|
501
528
|
const mapDir = mapUrl ? path.dirname(url.fileURLToPath(mapUrl)) : path.dirname(scriptPath);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
529
|
+
function sourceUnits(data) {
|
|
530
|
+
const root = typeof data.sourceRoot === "string" ? data.sourceRoot : "";
|
|
531
|
+
return data.sources.map(function (src) {
|
|
532
|
+
if (typeof src !== "string") return null;
|
|
533
|
+
let abs;
|
|
534
|
+
if (src.startsWith("file://")) {
|
|
535
|
+
try {
|
|
536
|
+
abs = url.fileURLToPath(src);
|
|
537
|
+
} catch (e) {
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
} else if (/^[a-z][a-z0-9+.-]*:/i.test(src)) {
|
|
541
|
+
return src.indexOf("/node_modules/") >= 0 ? unitFromText(src) : null;
|
|
542
|
+
} else {
|
|
543
|
+
const rel = root && !src.startsWith("/") ? root.replace(/\\/$/, "") + "/" + src : src;
|
|
544
|
+
abs = path.resolve(mapDir, rel);
|
|
511
545
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
546
|
+
return abs.indexOf("/node_modules/") >= 0 ? unitOf(abs) : null;
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
function prepareMap(data, depth) {
|
|
550
|
+
if (!data || typeof data !== "object" || depth > 32) return false;
|
|
551
|
+
delete data.sourcesContent;
|
|
552
|
+
if (Array.isArray(data.sections)) {
|
|
553
|
+
if ("mappings" in data) return false;
|
|
554
|
+
return data.sections.every(function (section) {
|
|
555
|
+
return section && !section.url && prepareMap(section.map, depth + 1);
|
|
556
|
+
});
|
|
517
557
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
558
|
+
if (!Array.isArray(data.sources) || typeof data.mappings !== "string") return false;
|
|
559
|
+
data.x_spectestSourceUnits = sourceUnits(data);
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
if (!prepareMap(data, 0)) return null;
|
|
563
|
+
const units = data.x_spectestSourceUnits;
|
|
564
|
+
delete data.x_spectestSourceUnits;
|
|
565
|
+
return { url: mapUrl, data: data, sourceUnits: units };
|
|
521
566
|
}
|
|
522
567
|
function writeAtomic(file, text) {
|
|
523
568
|
const tmp = file + "." + process.pid + "-" + wt.threadId + ".tmp";
|
|
@@ -566,34 +611,44 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
566
611
|
if (kept.length === 0 && units.size === 0) return;
|
|
567
612
|
const fresh = [];
|
|
568
613
|
for (const s of kept) {
|
|
569
|
-
if (!known.has(s.scriptId)) fresh.push(s);
|
|
614
|
+
if (!known.has(s.scriptId) && !generatedUnits.has(s.scriptId)) fresh.push(s);
|
|
570
615
|
}
|
|
571
616
|
if (fresh.length > 0) {
|
|
572
617
|
try {
|
|
573
618
|
fs.mkdirSync(SCRIPTS, { recursive: true });
|
|
574
619
|
fs.mkdirSync(MARKERS, { recursive: true });
|
|
575
620
|
} catch (e) {}
|
|
576
|
-
post("Debugger.enable");
|
|
577
621
|
for (const s of fresh) {
|
|
578
622
|
const r = post("Debugger.getScriptSource", { scriptId: s.scriptId });
|
|
579
623
|
try {
|
|
580
|
-
|
|
624
|
+
const source = r && r.scriptSource;
|
|
625
|
+
if (GENERATED.has(s.url) && typeof source === "string") {
|
|
626
|
+
generatedUnits.set(s.scriptId, generatedUnit(GENERATED.get(s.url), source));
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
const id = describe(s, source);
|
|
630
|
+
// A failed source fetch remains visible in this dump, but is not
|
|
631
|
+
// memoized as a successful descriptor for subsequent captures.
|
|
632
|
+
if (typeof source === "string") known.set(s.scriptId, id);
|
|
633
|
+
s.missingSourceRecord = id;
|
|
581
634
|
} catch (e) {}
|
|
582
635
|
}
|
|
583
|
-
post("Debugger.disable");
|
|
584
636
|
}
|
|
585
637
|
// A ran function is one flat array: [start, length, count] for its
|
|
586
638
|
// extent (V8's first range), then [offset from start, length, count]
|
|
587
639
|
// per block range in V8's order. Small numbers, so the dump gzips to
|
|
588
640
|
// less than half of V8's own JSON; name and block flag live in the
|
|
589
641
|
// script record, joined on the extent.
|
|
642
|
+
for (const s of kept) {
|
|
643
|
+
if (generatedUnits.has(s.scriptId)) units.add(generatedUnits.get(s.scriptId));
|
|
644
|
+
}
|
|
590
645
|
const doc = {
|
|
591
646
|
spectestVersion: 2,
|
|
592
647
|
units: Array.from(units).sort(),
|
|
593
|
-
result: kept.map(function (s) {
|
|
648
|
+
result: kept.filter(function (s) { return !generatedUnits.has(s.scriptId); }).map(function (s) {
|
|
594
649
|
return {
|
|
595
650
|
url: s.url,
|
|
596
|
-
spectestScript: known.get(s.scriptId) || null,
|
|
651
|
+
spectestScript: known.get(s.scriptId) || s.missingSourceRecord || null,
|
|
597
652
|
functions: s.functions.filter(ran).map(function (f) {
|
|
598
653
|
const first = f.ranges[0];
|
|
599
654
|
const out = [first.startOffset, first.endOffset - first.startOffset, first.count];
|
|
@@ -774,7 +829,22 @@ for (;;) {
|
|
|
774
829
|
process.stdout.write(Array.from(out).sort().join("\\n") + (out.size ? "\\n" : ""));
|
|
775
830
|
`;
|
|
776
831
|
|
|
777
|
-
export
|
|
832
|
+
export interface NodeCoverageOptions {
|
|
833
|
+
/** Generated runtime entrypoints, relative to the service working directory.
|
|
834
|
+
* Track their exact contents as units rather than inventing repository lines.
|
|
835
|
+
* A content change changes the inventory and invalidates dependent tests. */
|
|
836
|
+
generated?: readonly string[];
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
export function node(options: NodeCoverageOptions = {}): CoverageAdapter {
|
|
840
|
+
if (options.generated !== undefined && (!Array.isArray(options.generated) || options.generated.some(p => typeof p !== "string"))) {
|
|
841
|
+
throw new Error("coverage.node generated must be a list of relative files");
|
|
842
|
+
}
|
|
843
|
+
const generated: string[] = [...new Set<string>(options.generated ?? [])].sort();
|
|
844
|
+
if (generated.some(p => !p || p.includes("\\") || p.includes(":") || p.includes("\0") ||
|
|
845
|
+
p.split("/").some(c => !c || c === "." || c === ".."))) {
|
|
846
|
+
throw new Error("coverage.node generated paths must be relative files without traversal");
|
|
847
|
+
}
|
|
778
848
|
return {
|
|
779
849
|
name: "node",
|
|
780
850
|
reports: "delta",
|
|
@@ -786,6 +856,14 @@ export function node(): CoverageAdapter {
|
|
|
786
856
|
const b64 = Buffer.from(NODE_INVENTORY_SCRIPT).toString("base64");
|
|
787
857
|
const r = await ctx.exec(`echo ${b64} | base64 -d | node -`);
|
|
788
858
|
const units = new Set<string>(r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("npm:")));
|
|
859
|
+
if (generated.length) {
|
|
860
|
+
const script = `const fs=require("node:fs"),crypto=require("node:crypto");
|
|
861
|
+
for(const file of ${JSON.stringify(generated)}) console.log(
|
|
862
|
+
${JSON.stringify("generated:" + ctx.service + ":")} + file + "#" +
|
|
863
|
+
crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"));`;
|
|
864
|
+
const result = await ctx.exec(`echo ${Buffer.from(script).toString("base64")} | base64 -d | node -`);
|
|
865
|
+
for (const unit of result.stdout.trim().split("\n")) if (unit) units.add(unit);
|
|
866
|
+
}
|
|
789
867
|
for (const f of await ctx.projectFiles()) {
|
|
790
868
|
if (f !== "package.json" && !f.endsWith("/package.json")) continue;
|
|
791
869
|
const text = await ctx.readProjectFile(f);
|
|
@@ -793,9 +871,13 @@ export function node(): CoverageAdapter {
|
|
|
793
871
|
}
|
|
794
872
|
return [...units].sort();
|
|
795
873
|
},
|
|
796
|
-
configure(svc) {
|
|
874
|
+
configure(svc, info) {
|
|
797
875
|
const env = appendEnvFlag(svc.env, "NODE_OPTIONS", `--require ${NODE_COVERAGE_HOOK_PATH}`);
|
|
798
876
|
env[NODE_COVERAGE_DIR_ENV] = COVERAGE_CONTAINER_DIR;
|
|
877
|
+
if (generated.length) {
|
|
878
|
+
env.SPECTEST_COVERAGE_GENERATED_FILES = JSON.stringify(generated);
|
|
879
|
+
env.SPECTEST_COVERAGE_SERVICE = info.key;
|
|
880
|
+
}
|
|
799
881
|
return {
|
|
800
882
|
...svc,
|
|
801
883
|
env,
|
package/src/daemon.ts
CHANGED
|
@@ -1351,7 +1351,7 @@ async function resolveDockerfileBuild(svc: NamedService): Promise<DockerfileBuil
|
|
|
1351
1351
|
throw new Error(`service "${svc.name}" image is not a Dockerfile build`);
|
|
1352
1352
|
}
|
|
1353
1353
|
const contextRel = image.context ?? ".";
|
|
1354
|
-
const contextDir = resolveExistingProjectPath(contextRel, `service "${svc.name}" build context
|
|
1354
|
+
const contextDir = resolveExistingProjectPath(contextRel, `service "${svc.name}" build context`, { record: false });
|
|
1355
1355
|
let content: string;
|
|
1356
1356
|
let adjacent: string | null = null;
|
|
1357
1357
|
let adjacentPath: string | null = null;
|
|
@@ -1576,7 +1576,10 @@ async function runServiceBuild(
|
|
|
1576
1576
|
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, contextDir];
|
|
1577
1577
|
}
|
|
1578
1578
|
progressService(name, { status: "building", detail: "starting build" });
|
|
1579
|
-
|
|
1579
|
+
// The controller owns the bootstrap/prepareImages deadline and tears
|
|
1580
|
+
// down the VM on expiry. A separate build timer would cut short an
|
|
1581
|
+
// environment's explicit timeoutSecs allowance (e.g. long Rust builds).
|
|
1582
|
+
const build = await shxStream("docker", buildArgs, undefined, buildEnv, (line) => {
|
|
1580
1583
|
// BuildKit `--progress=plain` declares each step as
|
|
1581
1584
|
// `#N [<stage> M/N] <cmd>`; the legacy builder as `Step M/N : <cmd>`.
|
|
1582
1585
|
// Track the most-recent step as live detail.
|
|
@@ -4068,6 +4071,9 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
|
|
|
4068
4071
|
}
|
|
4069
4072
|
const root = await coverageRoot(svc.name);
|
|
4070
4073
|
if (root) read.root = root;
|
|
4074
|
+
if (typeof svc.coverage === "object" && svc.coverage.sourceRoot !== undefined) {
|
|
4075
|
+
read.sourceRoot = svc.coverage.sourceRoot;
|
|
4076
|
+
}
|
|
4071
4077
|
// What ships is what ran during this capture, whatever the tool
|
|
4072
4078
|
// counts: cumulative lcov is subtracted against the previous
|
|
4073
4079
|
// capture (module memory, forks with the env); a delta adapter's
|
|
@@ -48,8 +48,8 @@ describe("source maps", () => {
|
|
|
48
48
|
expect(files.get("src/b.ts")!.get(5)).toBe(0);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
test("index maps
|
|
52
|
-
expect(decodeSourceMap({ version: 3, sections: [] })).
|
|
51
|
+
test("empty index maps are generated code; malformed maps remain unresolved", () => {
|
|
52
|
+
expect(decodeSourceMap({ version: 3, sections: [] })).toEqual({ sources: [], lines: [] });
|
|
53
53
|
expect(decodeSourceMap({ version: 3, sources: ["a"], mappings: "!!" })).toBeNull();
|
|
54
54
|
expect(decodeSourceMap("nope")).toBeNull();
|
|
55
55
|
});
|
|
@@ -112,3 +112,18 @@ describe("turbopack + inline scripts", () => {
|
|
|
112
112
|
expect(isDocumentScriptPath("/app.js")).toBe(false);
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
|
+
|
|
116
|
+
test("nested indexed maps apply section line and column offsets and reject overlaps", () => {
|
|
117
|
+
const leaf = { version: 3, sources: ["turbopack:///[project]/dashboard/a.ts"], mappings: "AAAA;AACA" };
|
|
118
|
+
const doc = { version: 3, sections: [
|
|
119
|
+
{ offset: { line: 1, column: 3 }, map: { version: 3, sections: [{offset: {line: 0, column: 2}, map: leaf}] } },
|
|
120
|
+
{ offset: { line: 3, column: 0 }, map: { version: 3, sources: ["b.ts"], mappings: "AAAA" } },
|
|
121
|
+
] };
|
|
122
|
+
const map = decodeSourceMap(doc)!;
|
|
123
|
+
expect(map.lines).toEqual([[], [{col:5, src:0, line:0}], [{col:0, src:0, line:1}], [{col:0, src:1, line:0}]]);
|
|
124
|
+
expect([...mapToOriginal(map, new Map([[2,1],[3,2],[4,3]])).keys()]).toEqual([leaf.sources[0], "b.ts"]);
|
|
125
|
+
expect(decodeSourceMap({version:3,sections:[{offset:{line:0,column:0},url:"remote.map"}]})).toBeNull();
|
|
126
|
+
expect(decodeSourceMap({...doc,sections:[doc.sections[1],doc.sections[0]]})).toBeNull();
|
|
127
|
+
expect(isForeignSource("turbopack:///[turbopack]/browser/runtime/base/runtime-base.ts")).toBe(true);
|
|
128
|
+
expect(isForeignSource("turbopack:///[project]/turbopack/runtime.ts")).toBe(false);
|
|
129
|
+
});
|
|
@@ -115,6 +115,7 @@ export function decodeVlq(segment: string): number[] {
|
|
|
115
115
|
shift = 0;
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
+
if (shift !== 0) throw new Error("unterminated VLQ");
|
|
118
119
|
return out;
|
|
119
120
|
}
|
|
120
121
|
|
|
@@ -152,6 +153,8 @@ export function normalizeSourcePath(src: string, sourceRoot?: string): string {
|
|
|
152
153
|
/** Sources nobody's change can be attributed to: dependencies and bundler
|
|
153
154
|
* runtime shims. */
|
|
154
155
|
export function isForeignSource(path: string): boolean {
|
|
156
|
+
// Preserve project namespaces; only the explicit Turbopack runtime is foreign.
|
|
157
|
+
if (path.startsWith("turbopack:///[turbopack]/")) return true;
|
|
155
158
|
return (
|
|
156
159
|
path.length === 0 ||
|
|
157
160
|
path.includes("node_modules/") ||
|
|
@@ -178,12 +181,13 @@ export function isDocumentScriptPath(pathname: string): boolean {
|
|
|
178
181
|
}
|
|
179
182
|
|
|
180
183
|
/**
|
|
181
|
-
* Decode
|
|
182
|
-
*
|
|
184
|
+
* Decode flat and embedded index maps. Unsupported external section URLs
|
|
185
|
+
* and malformed maps decode to `null` — the
|
|
183
186
|
* caller then falls back to attributing the script to its own URL, which
|
|
184
187
|
* the repo mapping cannot resolve, which is the safe answer.
|
|
185
188
|
*/
|
|
186
|
-
export function decodeSourceMap(doc: unknown): DecodedSourceMap | null {
|
|
189
|
+
export function decodeSourceMap(doc: unknown, depth = 0): DecodedSourceMap | null {
|
|
190
|
+
if (depth > 32) return null;
|
|
187
191
|
if (!doc || typeof doc !== "object") return null;
|
|
188
192
|
const m = doc as {
|
|
189
193
|
version?: number;
|
|
@@ -192,7 +196,34 @@ export function decodeSourceMap(doc: unknown): DecodedSourceMap | null {
|
|
|
192
196
|
mappings?: unknown;
|
|
193
197
|
sections?: unknown;
|
|
194
198
|
};
|
|
195
|
-
if (m.sections
|
|
199
|
+
if (m.sections !== undefined) {
|
|
200
|
+
if (!Array.isArray(m.sections) || m.mappings !== undefined) return null;
|
|
201
|
+
const out: DecodedSourceMap = { sources: [], lines: [] };
|
|
202
|
+
let previous: [number, number] | undefined;
|
|
203
|
+
for (const section of m.sections) {
|
|
204
|
+
const { line, column } = section?.offset ?? {};
|
|
205
|
+
if (section?.url !== undefined || !Number.isSafeInteger(line) || line < 0 ||
|
|
206
|
+
!Number.isSafeInteger(column) || column < 0 || line > 1_000_000 ||
|
|
207
|
+
(previous && (line < previous[0] || (line === previous[0] && column <= previous[1])))) return null;
|
|
208
|
+
previous = [line, column];
|
|
209
|
+
const child = decodeSourceMap(section.map, depth + 1);
|
|
210
|
+
if (!child) return null;
|
|
211
|
+
if (out.lines.some((segments, i) => i > line && segments.length > 0 ||
|
|
212
|
+
i === line && segments.some(s => s.col >= column))) return null;
|
|
213
|
+
const base = out.sources.length;
|
|
214
|
+
for (const source of child.sources) out.sources.push(source);
|
|
215
|
+
for (let i = 0; i < child.lines.length; i++) {
|
|
216
|
+
const target = line + i;
|
|
217
|
+
if (target > 1_000_000) return null;
|
|
218
|
+
while (out.lines.length <= target) out.lines.push([]);
|
|
219
|
+
for (const seg of child.lines[i]!) out.lines[target]!.push({
|
|
220
|
+
col: seg.col + (i === 0 ? column : 0), src: seg.src + base, line: seg.line,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
if (!Array.isArray(m.sources) || typeof m.mappings !== "string") {
|
|
196
227
|
return null;
|
|
197
228
|
}
|
|
198
229
|
const sourceRoot = typeof m.sourceRoot === "string" ? m.sourceRoot : undefined;
|
|
@@ -210,10 +241,13 @@ export function decodeSourceMap(doc: unknown): DecodedSourceMap | null {
|
|
|
210
241
|
for (const seg of group.split(",")) {
|
|
211
242
|
if (seg.length === 0) continue;
|
|
212
243
|
const f = decodeVlq(seg);
|
|
244
|
+
if (![1,4,5].includes(f.length)) return null;
|
|
213
245
|
col += f[0];
|
|
246
|
+
if (!Number.isSafeInteger(col) || col < 0) return null;
|
|
214
247
|
if (f.length >= 4) {
|
|
215
248
|
src += f[1];
|
|
216
249
|
line += f[2];
|
|
250
|
+
if (src < 0 || src >= sources.length || !Number.isSafeInteger(line) || line < 0) return null;
|
|
217
251
|
segs.push({ col, src, line });
|
|
218
252
|
}
|
|
219
253
|
}
|
package/src/harness/coverage.ts
CHANGED
|
@@ -76,6 +76,8 @@ export interface ServiceCoverageCapture {
|
|
|
76
76
|
* never has to fetch code to do so. Absent for a container it could
|
|
77
77
|
* not be read from. */
|
|
78
78
|
root?: string;
|
|
79
|
+
/** Repository root of the service build/bundler workspace, when explicit. */
|
|
80
|
+
sourceRoot?: string;
|
|
79
81
|
/** Set when the capture failed: the command failed, the directory was
|
|
80
82
|
* empty, no file held an `SF:` record, or the reports were too large.
|
|
81
83
|
* A case with an error here fails — a hole in the coverage data must
|
package/src/index.ts
CHANGED
|
@@ -1384,7 +1384,7 @@ export function validateServiceImage(serviceName: string, input: ServiceImage):
|
|
|
1384
1384
|
`service "${serviceName}" image context must be a directory path relative to the project root (\`"."\` for the root)`,
|
|
1385
1385
|
);
|
|
1386
1386
|
}
|
|
1387
|
-
const dir = resolveExistingProjectPath(image.context, `service "${serviceName}" build context
|
|
1387
|
+
const dir = resolveExistingProjectPath(image.context, `service "${serviceName}" build context`, { record: false });
|
|
1388
1388
|
if (!statSync(dir).isDirectory()) {
|
|
1389
1389
|
throw new Error(
|
|
1390
1390
|
`service "${serviceName}" build context ${JSON.stringify(image.context)} is not a directory`,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
test("resolving a build context records no directory read, while source files remain covered", async () => {
|
|
7
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "spectest-project-reads-"));
|
|
8
|
+
try {
|
|
9
|
+
await mkdir(path.join(dir, "packages"));
|
|
10
|
+
await writeFile(path.join(dir, "packages/Dockerfile"), "FROM scratch\n");
|
|
11
|
+
const module = new URL("./project-files.ts", import.meta.url).pathname;
|
|
12
|
+
const script = `import {resolveExistingProjectPath,takeProjectReads} from ${JSON.stringify(module)};
|
|
13
|
+
import {validateServiceImage} from ${JSON.stringify(new URL("./index.ts", import.meta.url).pathname)};
|
|
14
|
+
resolveExistingProjectPath(".","build context",{record:false});
|
|
15
|
+
resolveExistingProjectPath("packages","build context",{record:false});
|
|
16
|
+
validateServiceImage("web",{type:"dockerfile",context:"packages",path:"packages/Dockerfile"});
|
|
17
|
+
console.log(JSON.stringify(takeProjectReads()));`;
|
|
18
|
+
const proc = Bun.spawn([process.execPath, "-e", script], {
|
|
19
|
+
env: {...process.env, SPECTEST_WORKSPACE:dir}, stdout:"pipe", stderr:"pipe",
|
|
20
|
+
});
|
|
21
|
+
const text = await new Response(proc.stdout).text();
|
|
22
|
+
expect(await proc.exited).toBe(0);
|
|
23
|
+
expect(JSON.parse(text)).toEqual(["packages/Dockerfile"]);
|
|
24
|
+
} finally { await rm(dir, {recursive:true, force:true}); }
|
|
25
|
+
});
|
package/src/project-files.ts
CHANGED
|
@@ -72,8 +72,8 @@ export function resolveProjectPath(p: string, opts: { record?: boolean } = {}):
|
|
|
72
72
|
/** {@link resolveProjectPath}, but a missing file is an error that names every
|
|
73
73
|
* place we looked. For inputs a user hands us by name — a fixture — where an
|
|
74
74
|
* ENOENT on one guessed path reads as a spectest bug rather than a typo. */
|
|
75
|
-
export function resolveExistingProjectPath(p: string, what = "file"): string {
|
|
76
|
-
const resolved = resolveProjectPath(p);
|
|
75
|
+
export function resolveExistingProjectPath(p: string, what = "file", opts: { record?: boolean } = {}): string {
|
|
76
|
+
const resolved = resolveProjectPath(p, opts);
|
|
77
77
|
if (existsSync(resolved)) return resolved;
|
|
78
78
|
const rel = relative(p);
|
|
79
79
|
const looked = path.isAbsolute(p)
|