@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.
@@ -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 declare function node(): CoverageAdapter;
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
- const root = typeof data.sourceRoot === "string" && data.sourceRoot ? data.sourceRoot : "";
357
- const sourceUnits = (Array.isArray(data.sources) ? data.sources : []).map(function (src) {
358
- if (typeof src !== "string") return null;
359
- let abs;
360
- if (src.startsWith("file://")) {
361
- try {
362
- abs = url.fileURLToPath(src);
363
- } catch (e) {
364
- return null;
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
- } else if (/^[a-z][a-z0-9+.-]*:/i.test(src)) {
367
- return src.indexOf("/node_modules/") >= 0 ? unitFromText(src) : null;
368
- } else {
369
- const rel = root && !src.startsWith("/") ? root.replace(/\\/$/, "") + "/" + src : src;
370
- abs = path.resolve(mapDir, rel);
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
- return abs.indexOf("/node_modules/") >= 0 ? unitOf(abs) : null;
373
- });
374
- return { url: mapUrl, data: data, sourceUnits: sourceUnits };
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
- known.set(s.scriptId, describe(s, r && r.scriptSource));
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
- Object.assign(svc, patch);
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 () => BOOTSTRAP_PROGRESS ?? {},
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 a source map document. Index maps (`sections`) are not
65
- * supported and decode to `null`, as does anything malformed — the
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 a source map document. Index maps (`sections`) are not
147
- * supported and decode to `null`, as does anything malformed — the
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 || !Array.isArray(m.sources) || typeof m.mappings !== "string") {
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
  }
@@ -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): string;
23
+ export declare function resolveExistingProjectPath(p: string, what?: string, opts?: {
24
+ record?: boolean;
25
+ }): string;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.119.1",
3
+ "version": "0.119.3",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
- | { adapters?: readonly CoverageAdapter[]; reports?: CoverageReportsMode };
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
- const root = typeof data.sourceRoot === "string" && data.sourceRoot ? data.sourceRoot : "";
503
- const sourceUnits = (Array.isArray(data.sources) ? data.sources : []).map(function (src) {
504
- if (typeof src !== "string") return null;
505
- let abs;
506
- if (src.startsWith("file://")) {
507
- try {
508
- abs = url.fileURLToPath(src);
509
- } catch (e) {
510
- return null;
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
- } else if (/^[a-z][a-z0-9+.-]*:/i.test(src)) {
513
- return src.indexOf("/node_modules/") >= 0 ? unitFromText(src) : null;
514
- } else {
515
- const rel = root && !src.startsWith("/") ? root.replace(/\\/$/, "") + "/" + src : src;
516
- abs = path.resolve(mapDir, rel);
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
- return abs.indexOf("/node_modules/") >= 0 ? unitOf(abs) : null;
519
- });
520
- return { url: mapUrl, data: data, sourceUnits: sourceUnits };
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
- known.set(s.scriptId, describe(s, r && r.scriptSource));
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 function node(): CoverageAdapter {
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
- Object.assign(svc, patch);
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 () => BOOTSTRAP_PROGRESS ?? {},
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 and garbage decode to null", () => {
52
- expect(decodeSourceMap({ version: 3, sections: [] })).toBeNull();
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 a source map document. Index maps (`sections`) are not
182
- * supported and decode to `null`, as does anything malformed — the
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 || !Array.isArray(m.sources) || typeof m.mappings !== "string") {
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
  }
@@ -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
+ });
@@ -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)