@specific.dev/spectest 0.119.1 → 0.119.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/coverage.d.ts +11 -1
- package/dist/coverage.js +99 -29
- package/dist/daemon.js +16 -5
- 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/harness/service-progress.d.ts +14 -0
- package/dist/harness/service-progress.js +24 -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 +15 -23
- 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/harness/service-progress.test.ts +48 -0
- package/src/harness/service-progress.ts +40 -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
|
@@ -4,6 +4,7 @@ import { runWithBackgroundFailures } from "./harness/background-failures.js";
|
|
|
4
4
|
import { generateHostCert as mintHostCert } from "./harness/certificate.js";
|
|
5
5
|
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
6
6
|
import { waitForAll } from "./harness/concurrency.js";
|
|
7
|
+
import { updateServiceProgress, serviceProgressSnapshot } from "./harness/service-progress.js";
|
|
7
8
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp } from "./android.js";
|
|
8
9
|
// Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
|
|
9
10
|
// a container). Owns:
|
|
@@ -426,13 +427,14 @@ function bootLogText() {
|
|
|
426
427
|
return head + BOOT_LOG.lines.join("");
|
|
427
428
|
}
|
|
428
429
|
let BOOTSTRAP_PROGRESS = null;
|
|
429
|
-
function progressInit(services) {
|
|
430
|
+
function progressInit(services, prepared) {
|
|
430
431
|
BOOTSTRAP_PROGRESS = {
|
|
431
432
|
phase: "Preparing images",
|
|
432
433
|
services: services.map((s) => ({
|
|
433
434
|
name: s.name,
|
|
434
435
|
kind: s.image.type === "registry" ? "pull" : "build",
|
|
435
436
|
status: "pending",
|
|
437
|
+
elapsedMs: prepared?.images[s.name]?.prepMs,
|
|
436
438
|
})),
|
|
437
439
|
startedAt: Date.now(),
|
|
438
440
|
updatedAt: Date.now(),
|
|
@@ -451,7 +453,7 @@ function progressService(name, patch) {
|
|
|
451
453
|
const svc = BOOTSTRAP_PROGRESS.services.find((s) => s.name === name);
|
|
452
454
|
if (!svc)
|
|
453
455
|
return;
|
|
454
|
-
|
|
456
|
+
updateServiceProgress(svc, patch);
|
|
455
457
|
BOOTSTRAP_PROGRESS.updatedAt = Date.now();
|
|
456
458
|
}
|
|
457
459
|
function progressDone() {
|
|
@@ -1003,7 +1005,7 @@ async function resolveDockerfileBuild(svc) {
|
|
|
1003
1005
|
throw new Error(`service "${svc.name}" image is not a Dockerfile build`);
|
|
1004
1006
|
}
|
|
1005
1007
|
const contextRel = image.context ?? ".";
|
|
1006
|
-
const contextDir = resolveExistingProjectPath(contextRel, `service "${svc.name}" build context
|
|
1008
|
+
const contextDir = resolveExistingProjectPath(contextRel, `service "${svc.name}" build context`, { record: false });
|
|
1007
1009
|
let content;
|
|
1008
1010
|
let adjacent = null;
|
|
1009
1011
|
let adjacentPath = null;
|
|
@@ -2719,7 +2721,7 @@ async function bootstrapInner(prepared, identity) {
|
|
|
2719
2721
|
Object.keys(prepared.images).sort().join("\n") !== tasks.map(s => s.name).sort().join("\n"))) {
|
|
2720
2722
|
throw new Error("prepared image manifest does not match the environment's services");
|
|
2721
2723
|
}
|
|
2722
|
-
progressInit(tasks);
|
|
2724
|
+
progressInit(tasks, prepared);
|
|
2723
2725
|
// Build dedup is only valid within one workspace generation — a fresh
|
|
2724
2726
|
// bootstrap may follow a workspace re-upload with the same dockerfile
|
|
2725
2727
|
// text but different build-context content.
|
|
@@ -2787,6 +2789,7 @@ async function bootstrapInner(prepared, identity) {
|
|
|
2787
2789
|
for (const svc of tasks) {
|
|
2788
2790
|
const p = (async () => {
|
|
2789
2791
|
const t0 = Date.now();
|
|
2792
|
+
progressService(svc.name, { status: svc.image.type === "registry" ? "pulling" : "building" });
|
|
2790
2793
|
prepOffsets.set(svc.name, Date.now() - bootStart);
|
|
2791
2794
|
const { tag, buildSteps } = await prepImage(svc);
|
|
2792
2795
|
progressService(svc.name, { status: "prepared", detail: undefined });
|
|
@@ -3439,6 +3442,9 @@ async function captureServiceCoverage(baseline = false) {
|
|
|
3439
3442
|
const root = await coverageRoot(svc.name);
|
|
3440
3443
|
if (root)
|
|
3441
3444
|
read.root = root;
|
|
3445
|
+
if (typeof svc.coverage === "object" && svc.coverage.sourceRoot !== undefined) {
|
|
3446
|
+
read.sourceRoot = svc.coverage.sourceRoot;
|
|
3447
|
+
}
|
|
3442
3448
|
// What ships is what ran during this capture, whatever the tool
|
|
3443
3449
|
// counts: cumulative lcov is subtracted against the previous
|
|
3444
3450
|
// capture (module memory, forks with the env); a delta adapter's
|
|
@@ -5170,7 +5176,12 @@ export function harnessMethods(state) {
|
|
|
5170
5176
|
// Live bootstrap progress, polled by the control plane during
|
|
5171
5177
|
// `bootstrap` and streamed into the test-run row. `{}` before the
|
|
5172
5178
|
// first bootstrap of this harness process.
|
|
5173
|
-
progress: async () =>
|
|
5179
|
+
progress: async () => {
|
|
5180
|
+
if (!BOOTSTRAP_PROGRESS)
|
|
5181
|
+
return {};
|
|
5182
|
+
const now = Date.now();
|
|
5183
|
+
return { ...BOOTSTRAP_PROGRESS, services: BOOTSTRAP_PROGRESS.services.map(s => serviceProgressSnapshot(s, now)) };
|
|
5184
|
+
},
|
|
5174
5185
|
/**
|
|
5175
5186
|
* Live logs for a running environment: the harness's own boot log
|
|
5176
5187
|
* (bootstrap plus every `setup` hook's console output) and each
|
|
@@ -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
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Timing for each service's active build/start work, excluding dependency waits. */
|
|
2
|
+
export type ServiceProgressStatus = "pending" | "pulling" | "building" | "prepared" | "starting" | "probing" | "ready" | "failed";
|
|
3
|
+
export interface ServiceProgress {
|
|
4
|
+
name: string;
|
|
5
|
+
kind: "pull" | "build";
|
|
6
|
+
status: ServiceProgressStatus;
|
|
7
|
+
detail?: string;
|
|
8
|
+
elapsedMs?: number;
|
|
9
|
+
/** Start of the current active interval; omitted from the wire snapshot. */
|
|
10
|
+
activeSince?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function updateServiceProgress(service: ServiceProgress, patch: Partial<ServiceProgress>, now?: number): void;
|
|
13
|
+
/** Calculate at poll time, even when the build has emitted no new messages. */
|
|
14
|
+
export declare function serviceProgressSnapshot(service: ServiceProgress, now?: number): Omit<ServiceProgress, "activeSince">;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
function active(status) {
|
|
2
|
+
return ["pulling", "building", "starting", "probing"].includes(status);
|
|
3
|
+
}
|
|
4
|
+
export function updateServiceProgress(service, patch, now = Date.now()) {
|
|
5
|
+
const wasActive = active(service.status);
|
|
6
|
+
const isActive = active(patch.status ?? service.status);
|
|
7
|
+
if (!wasActive && isActive) {
|
|
8
|
+
service.activeSince = now;
|
|
9
|
+
service.elapsedMs ??= 0;
|
|
10
|
+
}
|
|
11
|
+
else if (wasActive && !isActive && service.activeSince !== undefined) {
|
|
12
|
+
service.elapsedMs = (service.elapsedMs ?? 0) + Math.max(0, now - service.activeSince);
|
|
13
|
+
service.activeSince = undefined;
|
|
14
|
+
}
|
|
15
|
+
Object.assign(service, patch);
|
|
16
|
+
}
|
|
17
|
+
/** Calculate at poll time, even when the build has emitted no new messages. */
|
|
18
|
+
export function serviceProgressSnapshot(service, now = Date.now()) {
|
|
19
|
+
const { activeSince, ...snapshot } = service;
|
|
20
|
+
if (activeSince !== undefined) {
|
|
21
|
+
snapshot.elapsedMs = (service.elapsedMs ?? 0) + Math.max(0, now - activeSince);
|
|
22
|
+
}
|
|
23
|
+
return snapshot;
|
|
24
|
+
}
|
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
|
@@ -4,6 +4,7 @@ import { runWithBackgroundFailures } from "./harness/background-failures.js";
|
|
|
4
4
|
import { generateHostCert as mintHostCert } from "./harness/certificate.js";
|
|
5
5
|
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
6
6
|
import { waitForAll } from "./harness/concurrency.js";
|
|
7
|
+
import { updateServiceProgress, serviceProgressSnapshot, type ServiceProgress } from "./harness/service-progress.js";
|
|
7
8
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
|
|
8
9
|
// Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
|
|
9
10
|
// a container). Owns:
|
|
@@ -679,24 +680,6 @@ function bootLogText(): string {
|
|
|
679
680
|
// streams this snapshot into the test-run row, so the web UI shows what's
|
|
680
681
|
// being built *before* the suite starts. In-memory only; reset at the top
|
|
681
682
|
// of each bootstrap(). All updaters no-op when no bootstrap is in flight.
|
|
682
|
-
type ServiceProgressStatus =
|
|
683
|
-
| "pending"
|
|
684
|
-
| "pulling"
|
|
685
|
-
| "building"
|
|
686
|
-
| "prepared"
|
|
687
|
-
| "starting"
|
|
688
|
-
| "probing"
|
|
689
|
-
| "ready"
|
|
690
|
-
| "failed";
|
|
691
|
-
|
|
692
|
-
interface ServiceProgress {
|
|
693
|
-
name: string;
|
|
694
|
-
kind: "pull" | "build";
|
|
695
|
-
status: ServiceProgressStatus;
|
|
696
|
-
/** Live free-text detail, e.g. "step 4/9 RUN bun install" or "12 layers". */
|
|
697
|
-
detail?: string;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
683
|
interface BootstrapProgress {
|
|
701
684
|
phase: string;
|
|
702
685
|
services: ServiceProgress[];
|
|
@@ -707,13 +690,14 @@ interface BootstrapProgress {
|
|
|
707
690
|
|
|
708
691
|
let BOOTSTRAP_PROGRESS: BootstrapProgress | null = null;
|
|
709
692
|
|
|
710
|
-
function progressInit(services: NamedService[]): void {
|
|
693
|
+
function progressInit(services: NamedService[], prepared?: PreparedImages): void {
|
|
711
694
|
BOOTSTRAP_PROGRESS = {
|
|
712
695
|
phase: "Preparing images",
|
|
713
696
|
services: services.map((s) => ({
|
|
714
697
|
name: s.name,
|
|
715
698
|
kind: s.image.type === "registry" ? "pull" : "build",
|
|
716
699
|
status: "pending",
|
|
700
|
+
elapsedMs: prepared?.images[s.name]?.prepMs,
|
|
717
701
|
})),
|
|
718
702
|
startedAt: Date.now(),
|
|
719
703
|
updatedAt: Date.now(),
|
|
@@ -731,7 +715,7 @@ function progressService(name: string, patch: Partial<ServiceProgress>): void {
|
|
|
731
715
|
if (!BOOTSTRAP_PROGRESS) return;
|
|
732
716
|
const svc = BOOTSTRAP_PROGRESS.services.find((s) => s.name === name);
|
|
733
717
|
if (!svc) return;
|
|
734
|
-
|
|
718
|
+
updateServiceProgress(svc, patch);
|
|
735
719
|
BOOTSTRAP_PROGRESS.updatedAt = Date.now();
|
|
736
720
|
}
|
|
737
721
|
|
|
@@ -1351,7 +1335,7 @@ async function resolveDockerfileBuild(svc: NamedService): Promise<DockerfileBuil
|
|
|
1351
1335
|
throw new Error(`service "${svc.name}" image is not a Dockerfile build`);
|
|
1352
1336
|
}
|
|
1353
1337
|
const contextRel = image.context ?? ".";
|
|
1354
|
-
const contextDir = resolveExistingProjectPath(contextRel, `service "${svc.name}" build context
|
|
1338
|
+
const contextDir = resolveExistingProjectPath(contextRel, `service "${svc.name}" build context`, { record: false });
|
|
1355
1339
|
let content: string;
|
|
1356
1340
|
let adjacent: string | null = null;
|
|
1357
1341
|
let adjacentPath: string | null = null;
|
|
@@ -3254,7 +3238,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3254
3238
|
Object.keys(prepared.images).sort().join("\n") !== tasks.map(s => s.name).sort().join("\n"))) {
|
|
3255
3239
|
throw new Error("prepared image manifest does not match the environment's services");
|
|
3256
3240
|
}
|
|
3257
|
-
progressInit(tasks);
|
|
3241
|
+
progressInit(tasks, prepared);
|
|
3258
3242
|
// Build dedup is only valid within one workspace generation — a fresh
|
|
3259
3243
|
// bootstrap may follow a workspace re-upload with the same dockerfile
|
|
3260
3244
|
// text but different build-context content.
|
|
@@ -3324,6 +3308,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3324
3308
|
for (const svc of tasks) {
|
|
3325
3309
|
const p = (async () => {
|
|
3326
3310
|
const t0 = Date.now();
|
|
3311
|
+
progressService(svc.name, { status: svc.image.type === "registry" ? "pulling" : "building" });
|
|
3327
3312
|
prepOffsets.set(svc.name, Date.now() - bootStart);
|
|
3328
3313
|
const { tag, buildSteps } = await prepImage(svc);
|
|
3329
3314
|
progressService(svc.name, { status: "prepared", detail: undefined });
|
|
@@ -4071,6 +4056,9 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
|
|
|
4071
4056
|
}
|
|
4072
4057
|
const root = await coverageRoot(svc.name);
|
|
4073
4058
|
if (root) read.root = root;
|
|
4059
|
+
if (typeof svc.coverage === "object" && svc.coverage.sourceRoot !== undefined) {
|
|
4060
|
+
read.sourceRoot = svc.coverage.sourceRoot;
|
|
4061
|
+
}
|
|
4074
4062
|
// What ships is what ran during this capture, whatever the tool
|
|
4075
4063
|
// counts: cumulative lcov is subtracted against the previous
|
|
4076
4064
|
// capture (module memory, forks with the env); a delta adapter's
|
|
@@ -6190,7 +6178,11 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6190
6178
|
// Live bootstrap progress, polled by the control plane during
|
|
6191
6179
|
// `bootstrap` and streamed into the test-run row. `{}` before the
|
|
6192
6180
|
// first bootstrap of this harness process.
|
|
6193
|
-
progress: async () =>
|
|
6181
|
+
progress: async () => {
|
|
6182
|
+
if (!BOOTSTRAP_PROGRESS) return {};
|
|
6183
|
+
const now = Date.now();
|
|
6184
|
+
return { ...BOOTSTRAP_PROGRESS, services: BOOTSTRAP_PROGRESS.services.map(s => serviceProgressSnapshot(s, now)) };
|
|
6185
|
+
},
|
|
6194
6186
|
|
|
6195
6187
|
/**
|
|
6196
6188
|
* Live logs for a running environment: the harness's own boot log
|
|
@@ -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
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { serviceProgressSnapshot, updateServiceProgress, type ServiceProgress } from "./service-progress";
|
|
3
|
+
|
|
4
|
+
function service(): ServiceProgress {
|
|
5
|
+
return { name: "api", kind: "build", status: "pending" };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
test("measures quiet builds without resetting on status or detail updates", () => {
|
|
9
|
+
const s = service();
|
|
10
|
+
expect(serviceProgressSnapshot(s, 1000).elapsedMs).toBeUndefined();
|
|
11
|
+
updateServiceProgress(s, { status: "building" }, 1000);
|
|
12
|
+
updateServiceProgress(s, { status: "building", detail: "RUN install" }, 2000);
|
|
13
|
+
expect(serviceProgressSnapshot(s, 6000).elapsedMs).toBe(5000);
|
|
14
|
+
expect(serviceProgressSnapshot(s, 6000)).not.toHaveProperty("activeSince");
|
|
15
|
+
expect(s.elapsedMs).toBe(0); // Polling must not accumulate the interval twice.
|
|
16
|
+
expect(serviceProgressSnapshot(s, 7000).elapsedMs).toBe(6000);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("freezes while prepared, resumes for startup, and keeps the ready duration", () => {
|
|
20
|
+
const s = service();
|
|
21
|
+
updateServiceProgress(s, { status: "pulling" }, 1000);
|
|
22
|
+
updateServiceProgress(s, { status: "prepared" }, 5000);
|
|
23
|
+
updateServiceProgress(s, { status: "prepared", detail: undefined }, 6000);
|
|
24
|
+
expect(serviceProgressSnapshot(s, 20000).elapsedMs).toBe(4000);
|
|
25
|
+
updateServiceProgress(s, { status: "starting" }, 20000);
|
|
26
|
+
updateServiceProgress(s, { status: "probing" }, 21000);
|
|
27
|
+
updateServiceProgress(s, { status: "probing", detail: "running setup" }, 22000);
|
|
28
|
+
expect(serviceProgressSnapshot(s, 23000).elapsedMs).toBe(7000);
|
|
29
|
+
updateServiceProgress(s, { status: "ready" }, 24000);
|
|
30
|
+
expect(serviceProgressSnapshot(s, 30000).elapsedMs).toBe(8000);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("retains elapsed time on failure and never returns a negative interval", () => {
|
|
34
|
+
const s = service();
|
|
35
|
+
updateServiceProgress(s, { status: "building" }, 1000);
|
|
36
|
+
expect(serviceProgressSnapshot(s, 500).elapsedMs).toBe(0);
|
|
37
|
+
updateServiceProgress(s, { status: "failed" }, 3000);
|
|
38
|
+
expect(serviceProgressSnapshot(s, 20000).elapsedMs).toBe(2000);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("carries image preparation time across a separate builder handoff", () => {
|
|
42
|
+
const s = { ...service(), elapsedMs: 12500 };
|
|
43
|
+
updateServiceProgress(s, { status: "building" }, 1000);
|
|
44
|
+
updateServiceProgress(s, { status: "prepared" }, 1100);
|
|
45
|
+
updateServiceProgress(s, { status: "starting" }, 3000);
|
|
46
|
+
updateServiceProgress(s, { status: "ready" }, 4000);
|
|
47
|
+
expect(serviceProgressSnapshot(s, 9000).elapsedMs).toBe(13600);
|
|
48
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Timing for each service's active build/start work, excluding dependency waits. */
|
|
2
|
+
export type ServiceProgressStatus =
|
|
3
|
+
| "pending" | "pulling" | "building" | "prepared"
|
|
4
|
+
| "starting" | "probing" | "ready" | "failed";
|
|
5
|
+
|
|
6
|
+
export interface ServiceProgress {
|
|
7
|
+
name: string;
|
|
8
|
+
kind: "pull" | "build";
|
|
9
|
+
status: ServiceProgressStatus;
|
|
10
|
+
detail?: string;
|
|
11
|
+
elapsedMs?: number;
|
|
12
|
+
/** Start of the current active interval; omitted from the wire snapshot. */
|
|
13
|
+
activeSince?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function active(status: ServiceProgressStatus): boolean {
|
|
17
|
+
return ["pulling", "building", "starting", "probing"].includes(status);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function updateServiceProgress(service: ServiceProgress, patch: Partial<ServiceProgress>, now = Date.now()): void {
|
|
21
|
+
const wasActive = active(service.status);
|
|
22
|
+
const isActive = active(patch.status ?? service.status);
|
|
23
|
+
if (!wasActive && isActive) {
|
|
24
|
+
service.activeSince = now;
|
|
25
|
+
service.elapsedMs ??= 0;
|
|
26
|
+
} else if (wasActive && !isActive && service.activeSince !== undefined) {
|
|
27
|
+
service.elapsedMs = (service.elapsedMs ?? 0) + Math.max(0, now - service.activeSince);
|
|
28
|
+
service.activeSince = undefined;
|
|
29
|
+
}
|
|
30
|
+
Object.assign(service, patch);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Calculate at poll time, even when the build has emitted no new messages. */
|
|
34
|
+
export function serviceProgressSnapshot(service: ServiceProgress, now = Date.now()): Omit<ServiceProgress, "activeSince"> {
|
|
35
|
+
const { activeSince, ...snapshot } = service;
|
|
36
|
+
if (activeSince !== undefined) {
|
|
37
|
+
snapshot.elapsedMs = (service.elapsedMs ?? 0) + Math.max(0, now - activeSince);
|
|
38
|
+
}
|
|
39
|
+
return snapshot;
|
|
40
|
+
}
|
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)
|