@specific.dev/spectest 0.127.0 → 0.128.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,12 +2,14 @@ import type { ServiceConfig } from "./index.js";
2
2
  import { COVERAGE_CONTAINER_DIR, type CoverageReportsMode } from "./harness/coverage.js";
3
3
  export { COVERAGE_CONTAINER_DIR, type CoverageReportsMode };
4
4
  export { next, type NextCoverageOptions } from "./coverage-next.js";
5
+ export { bun, type BunCoverageOptions } from "./coverage-bun.js";
6
+ export { rust, rustHelperSource, type RustCoverageOptions } from "./coverage-rust.js";
5
7
  /** Where `node()` mounts its hook inside the container. */
6
8
  export declare const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
7
9
  /** The report `node()` writes when nothing has run yet on a branch. */
8
10
  export declare const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
9
11
  /** Prefix of the per-process sockets the node hook answers on, relative
10
- * to the coverage dir: `.ctl-<pid>`. */
12
+ * to the coverage dir: `.ctl-<pid>-<nonce>`. */
11
13
  export declare const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
12
14
  /** What `configure` learns about the service it rewrites. */
13
15
  export interface CoverageConfigureInfo {
@@ -111,6 +113,10 @@ export interface CoverageAdapter {
111
113
  load?(ctx: CoverageLoadContext): void | Promise<void>;
112
114
  /** Make the report appear in the directory. A throw fails the test. */
113
115
  capture?(ctx: CoverageCaptureContext): void | Promise<void>;
116
+ /** Component-owned containers (for example k3s pods). Their reports retain
117
+ * their own source roots and counter baselines, rather than the outer
118
+ * service's filesystem identity. Internal: ordinary adapters use capture. */
119
+ collect?(ctx: CoverageInventoryContext & CoverageCaptureContext, baseline: boolean): Promise<import("./harness/coverage.js").ServiceCoverageCapture[]>;
114
120
  /**
115
121
  * Project paths (gitignore-style patterns, project-relative) whose
116
122
  * changes this adapter's **inventory** accounts for — a package
@@ -218,7 +224,7 @@ export declare const NODE_COVERAGE_DUMP_PREFIX = "node-";
218
224
  * without `--enable-source-maps`. For a bundled CLI that was 12 MB per
219
225
  * invocation, inside the test's own time, parsed again at capture.
220
226
  * - **Takes** on request — the main thread binds a Unix socket
221
- * `.ctl-<pid>` in the coverage directory and takes on `dump\n`; every
227
+ * `.ctl-<pid>-<nonce>` in the coverage directory and takes on `dump\n`; every
222
228
  * process binds its own (the first node process is often a wrapper,
223
229
  * `pnpm exec`, `tsx`, `npm run`) — and in every thread's `exit`
224
230
  * handler, which is how a short-lived CLI and a worker thread report.
@@ -307,9 +313,9 @@ export interface NodeCoverageOptions extends CoverageSourceOptions {
307
313
  export declare function node(options?: NodeCoverageOptions): CoverageAdapter;
308
314
  /**
309
315
  * Ask every node process that holds a hook socket in `dir` for a dump.
310
- * A socket nobody answers (its process died without unlinking — SIGKILL,
311
- * OOM) is removed. Returns how many processes answered; a process that
312
- * answered with an error throws.
316
+ * An unanswered socket with a live marker means the process died without
317
+ * a final report and fails collection. Stale sockets without live markers
318
+ * are removed. Returns how many processes answered; error replies throw.
313
319
  */
314
320
  export declare function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Promise<number>;
315
321
  /** Forget the branch memory (tests). */
package/dist/coverage.js CHANGED
@@ -32,12 +32,14 @@ import { harvestAllBrowserCoverage, takeBrowserCoverageReport } from "./browser-
32
32
  import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
33
33
  export { COVERAGE_CONTAINER_DIR };
34
34
  export { next } from "./coverage-next.js";
35
+ export { bun } from "./coverage-bun.js";
36
+ export { rust, rustHelperSource } from "./coverage-rust.js";
35
37
  /** Where `node()` mounts its hook inside the container. */
36
38
  export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
37
39
  /** The report `node()` writes when nothing has run yet on a branch. */
38
40
  export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
39
41
  /** Prefix of the per-process sockets the node hook answers on, relative
40
- * to the coverage dir: `.ctl-<pid>`. */
42
+ * to the coverage dir: `.ctl-<pid>-<nonce>`. */
41
43
  export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
42
44
  /** The report name the harness writes an adapter's inventory to, in the
43
45
  * bring-up capture only. */
@@ -87,7 +89,7 @@ export function validateCoverage(service, cov) {
87
89
  (a.explains === undefined ||
88
90
  (Array.isArray(a.explains) &&
89
91
  a.explains.every((e) => typeof e === "string"))) &&
90
- ["configure", "load", "capture", "inventory", "ownsReport"].every((k) => a[k] === undefined ||
92
+ ["configure", "load", "capture", "collect", "inventory", "ownsReport"].every((k) => a[k] === undefined ||
91
93
  typeof a[k] === "function");
92
94
  const sourceOptions = a?.sourceOptions;
93
95
  const defaults = a?.defaultExcludeSources;
@@ -224,7 +226,7 @@ export const NODE_COVERAGE_DUMP_PREFIX = "node-";
224
226
  * without `--enable-source-maps`. For a bundled CLI that was 12 MB per
225
227
  * invocation, inside the test's own time, parsed again at capture.
226
228
  * - **Takes** on request — the main thread binds a Unix socket
227
- * `.ctl-<pid>` in the coverage directory and takes on `dump\n`; every
229
+ * `.ctl-<pid>-<nonce>` in the coverage directory and takes on `dump\n`; every
228
230
  * process binds its own (the first node process is often a wrapper,
229
231
  * `pnpm exec`, `tsx`, `npm run`) — and in every thread's `exit`
230
232
  * handler, which is how a short-lived CLI and a worker thread report.
@@ -274,6 +276,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
274
276
  (function () {
275
277
  const dir = process.env[${JSON.stringify(NODE_COVERAGE_DIR_ENV)}];
276
278
  if (!dir) return;
279
+ const bunRuntime = !!process.versions.bun && process.env.SPECTEST_COVERAGE_RUNTIME === "bun";
277
280
  let inspector, fs, path, crypto, url, wt, net;
278
281
  try {
279
282
  inspector = require("node:inspector");
@@ -294,19 +297,46 @@ export const NODE_COVERAGE_HOOK = `"use strict";
294
297
  }
295
298
  // An in-process session answers before post() returns.
296
299
  function post(method, params) {
300
+ // Bun's callback API queues a microtask, including during process exit.
301
+ // Its synchronous return API is necessary for short-lived commands.
302
+ if (bunRuntime) return session.post(method, params || {});
297
303
  let out = null;
298
304
  session.post(method, params || {}, function (err, res) {
299
305
  out = err ? null : res;
300
306
  });
301
307
  return out;
302
308
  }
303
- if (post("Profiler.enable") === null) return;
304
- post("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
309
+ fs.mkdirSync(dir, { recursive: true });
310
+ function fail(error) {
311
+ fs.writeFileSync(path.join(dir, "coverage-error-" + process.pid + ".txt"), String(error));
312
+ }
313
+ try {
314
+ if (post("Profiler.enable") === null) throw new Error("coverage profiler unavailable");
315
+ if (!post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })) throw new Error("precise coverage unavailable");
316
+ if (bunRuntime && (!process.versions.bun || !wt.isMainThread)) throw new Error("Bun coverage requires a supported main-thread runtime");
317
+ } catch (error) { fail(error); throw error; }
318
+ let bunRangeAdjustment = 0;
319
+ if (bunRuntime) {
320
+ // Bun releases have exposed both JSC-inclusive and V8-exclusive range
321
+ // ends. Probe the actual runtime before app code loads: interpreting an
322
+ // inclusive closing brace as exclusive attributes its source-map token
323
+ // to startup, making untouched function bodies appear boot-executed.
324
+ const probe = "let x=0;\\nfunction spectestCoverageProbe(){return 1;}\\nspectestCoverageProbe();\\n//# sourceURL=spectest-coverage-probe";
325
+ eval(probe);
326
+ const measured = post("Profiler.takePreciseCoverage");
327
+ const script = measured && measured.result.find(s => s.url === "spectest-coverage-probe");
328
+ const fn = script && script.functions.find(f => f.ranges[0].startOffset === probe.indexOf("function"));
329
+ if (!fn) { const error = new Error("Bun precise coverage capability probe failed"); fail(error); throw error; }
330
+ bunRangeAdjustment = probe.indexOf("}") + 1 - fn.ranges[0].endOffset;
331
+ if (bunRangeAdjustment !== 0 && bunRangeAdjustment !== 1) {
332
+ const error = new Error("unsupported Bun coverage range semantics"); fail(error); throw error;
333
+ }
334
+ }
305
335
 
306
336
  // Attach before app scripts execute. Enabling only at capture loses the
307
337
  // source of collected vm contexts (e.g. Temporal workflow bundles).
308
338
  // Bound V8's collected-script cache; live sources remain owned by V8.
309
- post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
339
+ if (!bunRuntime) post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
310
340
 
311
341
  const GENERATED = new Map();
312
342
  try {
@@ -325,6 +355,10 @@ export const NODE_COVERAGE_HOOK = `"use strict";
325
355
  // can be reused by another process, vm context, or compilation.
326
356
  const known = new Map();
327
357
  let seq = 0;
358
+ const processToken = process.pid + "-" + crypto.randomBytes(6).toString("hex");
359
+ const liveMarker = path.join(dir, ".live-" + processToken + "-" + wt.threadId);
360
+ fs.writeFileSync(liveMarker, "");
361
+ fs.writeFileSync(path.join(dir, ".attached-" + (bunRuntime ? "bun" : "node")), "");
328
362
 
329
363
  function isApp(u) {
330
364
  return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") < 0 && u.indexOf("/.cache/") < 0 && u !== SELF;
@@ -500,6 +534,12 @@ export const NODE_COVERAGE_HOOK = `"use strict";
500
534
  const kept = [];
501
535
  const units = new Set();
502
536
  for (const s of res.result) {
537
+ if (bunRuntime && bunRangeAdjustment && Array.isArray(s.functions)) {
538
+ const length = Math.max(...s.functions.flatMap(f => f.ranges.map(r => r.endOffset)));
539
+ for (const f of s.functions) for (const range of f.ranges) {
540
+ if (range.endOffset < length) range.endOffset += bunRangeAdjustment;
541
+ }
542
+ }
503
543
  if (!Array.isArray(s.functions) || !s.functions.some(ran)) continue;
504
544
  if (isApp(s.url)) kept.push(s);
505
545
  else if (isDep(s.url)) {
@@ -521,7 +561,18 @@ export const NODE_COVERAGE_HOOK = `"use strict";
521
561
  fs.mkdirSync(MARKERS, { recursive: true });
522
562
  } catch (e) {}
523
563
  for (const s of fresh) {
524
- const r = post("Debugger.getScriptSource", { scriptId: s.scriptId });
564
+ let r;
565
+ if (bunRuntime) {
566
+ // Bun does not expose getScriptSource through the in-process API.
567
+ // Accept only precompiled, untranspiled bundles, never guess that
568
+ // disk TypeScript offsets describe executed JavaScript.
569
+ const source = fs.readFileSync(url.fileURLToPath(s.url), "utf8");
570
+ const extent = Math.max(...s.functions.flatMap(f => f.ranges.map(r => r.endOffset)));
571
+ if (!source.startsWith("// @bun\\n") || source.length !== extent) {
572
+ throw new Error("Bun coverage needs an unchanged bun build --target=bun bundle with source maps: " + s.url);
573
+ }
574
+ r = { scriptSource: source };
575
+ } else r = post("Debugger.getScriptSource", { scriptId: s.scriptId });
525
576
  try {
526
577
  const source = r && r.scriptSource;
527
578
  if (GENERATED.has(s.url) && typeof source === "string") {
@@ -563,12 +614,12 @@ export const NODE_COVERAGE_HOOK = `"use strict";
563
614
  };
564
615
  }),
565
616
  };
566
- const name = ${JSON.stringify(NODE_COVERAGE_DUMP_PREFIX)} + process.pid + "-" + wt.threadId + "-" + Date.now() + "-" + seq++ + ".json";
617
+ const name = ${JSON.stringify(NODE_COVERAGE_DUMP_PREFIX)} + processToken + "-" + wt.threadId + "-" + Date.now() + "-" + seq++ + ".json";
567
618
  writeAtomic(path.join(dir, name), JSON.stringify(doc));
568
619
  }
569
620
 
570
621
  if (wt.isMainThread) {
571
- const SOCK = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + process.pid);
622
+ const SOCK = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + processToken);
572
623
  const server = net.createServer(function (conn) {
573
624
  let buf = "";
574
625
  conn.on("data", function (chunk) {
@@ -580,6 +631,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
580
631
  take();
581
632
  reply = "ok\\n";
582
633
  } catch (err) {
634
+ fail(err);
583
635
  reply = "error " + (err && err.message ? err.message : String(err)) + "\\n";
584
636
  }
585
637
  conn.end(reply);
@@ -600,7 +652,8 @@ export const NODE_COVERAGE_HOOK = `"use strict";
600
652
  process.on("exit", function () {
601
653
  try {
602
654
  take();
603
- } catch (e) {}
655
+ fs.unlinkSync(liveMarker);
656
+ } catch (e) { fail(e); }
604
657
  });
605
658
  })();
606
659
  `;
@@ -809,9 +862,9 @@ export function node(options = {}) {
809
862
  }
810
863
  /**
811
864
  * Ask every node process that holds a hook socket in `dir` for a dump.
812
- * A socket nobody answers (its process died without unlinking — SIGKILL,
813
- * OOM) is removed. Returns how many processes answered; a process that
814
- * answered with an error throws.
865
+ * An unanswered socket with a live marker means the process died without
866
+ * a final report and fails collection. Stale sockets without live markers
867
+ * are removed. Returns how many processes answered; error replies throw.
815
868
  */
816
869
  export async function dumpAllNodeProcesses(dir, signal) {
817
870
  const fs = await import("node:fs/promises");
@@ -850,6 +903,10 @@ export async function dumpAllNodeProcesses(dir, signal) {
850
903
  });
851
904
  });
852
905
  if (reply === null) {
906
+ const pid = name.slice(NODE_COVERAGE_SOCKET_PREFIX.length);
907
+ if ((await fs.readdir(dir)).some(marker => marker.startsWith(".live-" + pid + "-"))) {
908
+ throw new Error(`coverage process ${name} exited without a final report`);
909
+ }
853
910
  // Nobody home: the process is gone. Clean up so the next capture
854
911
  // does not knock again.
855
912
  await fs.unlink(sock).catch(() => { });
package/dist/daemon.js CHANGED
@@ -3277,7 +3277,11 @@ async function captureServiceCoverage(baseline = false) {
3277
3277
  for (const [name, s] of RUNTIME_SERVICES)
3278
3278
  byName.set(name, s);
3279
3279
  const all = [...byName.values()];
3280
- const services = all.filter((s) => s.coverage !== undefined);
3280
+ const covered = all.filter((s) => s.coverage !== undefined);
3281
+ const services = covered.filter(s => {
3282
+ const adapters = coverageAdapters(s.coverage);
3283
+ return !adapters.some(a => a.collect) || adapters.some(a => !a.collect);
3284
+ });
3281
3285
  // The environment's own capture is unconditional (see above); the
3282
3286
  // services' come from the ones that opted in, none included.
3283
3287
  const environment = captureEnvironmentCoverage(all, baseline);
@@ -3336,7 +3340,28 @@ async function captureServiceCoverage(baseline = false) {
3336
3340
  // files and V8 JSON pass through.
3337
3341
  return applyCoverageDelta(read, coverageReportsMode(svc.coverage), deltaReports);
3338
3342
  }));
3339
- return [...captures, await environment];
3343
+ const components = await Promise.all(covered.flatMap(svc => coverageAdapters(svc.coverage)
3344
+ .filter(a => a.collect).map(async (adapter) => {
3345
+ const signal = AbortSignal.timeout(COVERAGE_CAPTURE_TIMEOUT_MS);
3346
+ try {
3347
+ return await adapter.collect({
3348
+ ...coverageCaptureContext(svc, signal, new Set()),
3349
+ projectFiles: () => walkProjectFiles(WORKSPACE),
3350
+ async readProjectFile(p) {
3351
+ try {
3352
+ return await fs.readFile(resolveProjectPath(p, { record: false }), "utf8");
3353
+ }
3354
+ catch {
3355
+ return null;
3356
+ }
3357
+ },
3358
+ }, baseline);
3359
+ }
3360
+ catch (error) {
3361
+ return [{ service: `${svc.name}/${adapter.name}`, reports: [], error: String(error) }];
3362
+ }
3363
+ })));
3364
+ return [...captures, ...components.flat(), await environment];
3340
3365
  }
3341
3366
  /**
3342
3367
  * Mint a session id. `idScope` (the running test's case id; `"eval"`
@@ -1,6 +1,7 @@
1
1
  import type { BuildImage, ComponentImageBuild, ServiceConfig } from "../index.js";
2
2
  export type KubernetesImage = string | (BuildImage & {
3
3
  reference: string;
4
+ coverage?: import("../coverage.js").ServiceCoverage;
4
5
  });
5
6
  /** containerd/CRI use fully qualified names, including the default tag. */
6
7
  export declare function canonicalImageReference(reference: string): string;
@@ -47,8 +47,10 @@ export function kubernetesImages(images) {
47
47
  seen.set(reference, identity);
48
48
  if (typeof image === "string")
49
49
  pulls.push(image);
50
- else
51
- builds.push({ reference, image });
50
+ else {
51
+ const { coverage: _coverage, ...build } = image;
52
+ builds.push({ reference, image: image.coverage === undefined ? image : build });
53
+ }
52
54
  }
53
55
  return { pulls, builds };
54
56
  }
@@ -0,0 +1,10 @@
1
+ import { type CoverageCaptureContext, type CoverageInventoryContext, type ServiceCoverage } from "../coverage.js";
2
+ import { type ServiceCoverageCapture } from "./coverage.js";
3
+ export declare function captureCoverageTarget(options: {
4
+ context: CoverageCaptureContext & CoverageInventoryContext;
5
+ coverage: ServiceCoverage;
6
+ root: string;
7
+ baseline: boolean;
8
+ inventoryAvailable?: boolean;
9
+ counterKey?: string;
10
+ }): Promise<ServiceCoverageCapture>;
@@ -0,0 +1,54 @@
1
+ // Runtime adapters operate on this same context in Docker and nested runtimes.
2
+ // Counter baselines belong to an instance; source identity belongs to its image.
3
+ import { promises as fs } from "node:fs";
4
+ import path from "node:path";
5
+ import { attachCoverageSourceOptions, coverageAdapters, coverageReportsMode, resolveCoverageSourceOptions } from "../coverage.js";
6
+ import { applyCoverageDelta, readCoverageDir } from "./coverage.js";
7
+ export async function captureCoverageTarget(options) {
8
+ const { context: ctx, coverage, root, baseline } = options;
9
+ const adapters = coverageAdapters(coverage);
10
+ const written = new Set();
11
+ const delta = new Set();
12
+ const context = { ...ctx, async writeReport(name, content) {
13
+ const resolved = path.resolve(ctx.reportDir, name);
14
+ if (!resolved.startsWith(path.resolve(ctx.reportDir) + path.sep))
15
+ throw new Error("coverage report escapes directory");
16
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
17
+ await fs.writeFile(resolved + ".tmp", content);
18
+ await fs.rename(resolved + ".tmp", resolved);
19
+ written.add(name);
20
+ } };
21
+ try {
22
+ ctx.signal.throwIfAborted();
23
+ const policies = await resolveCoverageSourceOptions(adapters, ctx, typeof coverage === "object" ? {
24
+ sourceRoot: coverage.sourceRoot, excludeSources: coverage.excludeSources,
25
+ } : undefined);
26
+ for (const adapter of adapters) {
27
+ if (adapter.collect || adapter.load)
28
+ throw new Error(`adapter ${adapter.name} cannot run inside a container; attach browser coverage to the cluster service`);
29
+ written.clear();
30
+ await adapter.capture?.(context);
31
+ if (adapter.reports === "delta")
32
+ for (const name of written)
33
+ delta.add(name);
34
+ }
35
+ if (baseline && options.inventoryAvailable !== false) {
36
+ const units = new Set();
37
+ for (const adapter of adapters)
38
+ for (const unit of await adapter.inventory?.(context) ?? [])
39
+ units.add(unit);
40
+ await context.writeReport("inventory.units", [...units].sort().join("\n"));
41
+ }
42
+ ctx.signal.throwIfAborted();
43
+ const capture = await readCoverageDir(ctx.service, ctx.reportDir);
44
+ if (baseline)
45
+ await fs.rm(path.join(ctx.reportDir, "inventory.units"), { force: true });
46
+ capture.root = root;
47
+ attachCoverageSourceOptions(capture, policies);
48
+ const result = applyCoverageDelta({ ...capture, service: options.counterKey ?? capture.service }, coverageReportsMode(coverage), delta);
49
+ return { ...result, service: capture.service };
50
+ }
51
+ catch (error) {
52
+ return { service: ctx.service, reports: [], error: String(error) };
53
+ }
54
+ }
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ export { McpHttpError, McpRpcError, McpAuthDeniedError, } from "./mcp.js";
41
41
  import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, } from "./locator.js";
42
42
  import { escapeInvisible, textDifferenceNote, } from "./text-match.js";
43
43
  import { describeUrlPattern } from "./url-match.js";
44
- import { applyCoverageAdapters, validateCoverage } from "./coverage.js";
44
+ import { applyCoverageAdapters, coverageAdapters, validateCoverage } from "./coverage.js";
45
45
  import { resolveExistingProjectPath } from "./project-files.js";
46
46
  export { ca } from "./environment-ca.js";
47
47
  // Low-level ingress primitives + the framework lowering that the friendly
@@ -185,6 +185,15 @@ function expandServiceGroups(input) {
185
185
  // Rewrite relative dependsOn to final keys; a spread keeps function
186
186
  // fields (helpers/setup) and the enumerable-symbol ingress decls.
187
187
  const final = { ...svc, ...(part === entry.primary ? primaryOverrides : {}) };
188
+ // Component-owned nested containers keep their collection when callers
189
+ // add ordinary service coverage (for example browser coverage on k3s).
190
+ const collectors = coverageAdapters(svc.coverage).filter(adapter => adapter.collect);
191
+ if (part === entry.primary && collectors.length && primaryOverrides.coverage !== undefined) {
192
+ const extra = primaryOverrides.coverage;
193
+ final.coverage = { ...(typeof extra === "object" ? extra : {}), adapters: [
194
+ ...collectors, ...coverageAdapters(extra).filter(adapter => !collectors.includes(adapter)),
195
+ ] };
196
+ }
188
197
  if (final.dependsOn?.length) {
189
198
  final.dependsOn = final.dependsOn.map((d) => partKeys.has(d) ? naming.key(d) : d);
190
199
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.127.0",
3
+ "version": "0.128.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,160 @@
1
+ import { expect, test } from "bun:test";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { k3sCoverage } from "./k3s-coverage";
6
+ import { k3s } from "./k3s";
7
+ import { node, browser, rust, type CoverageAdapter } from "../coverage";
8
+ import { defineEnvironment } from "../index";
9
+ import type { Pod, Patch } from "./k3s-trust";
10
+ import { podTrustPatch } from "./k3s-trust";
11
+
12
+ function apply(pod: any, patches: Patch[]): any {
13
+ const out = structuredClone(pod);
14
+ for (const operation of patches) {
15
+ const keys = operation.path.slice(1).split("/");
16
+ const key = keys.pop()!;
17
+ const parent = keys.reduce((value, key) => value[key], out);
18
+ if (key === "-") parent.push(operation.value); else parent[key] = operation.value;
19
+ }
20
+ return out;
21
+ }
22
+ const digest = `images.test/api@sha256:${"a".repeat(64)}`;
23
+ const image = { reference: "images.test/api:test", content: "FROM node:24", coverage: { adapters: [node({ sourceRoot: "packages" })] } };
24
+ const dependencies = {
25
+ image: async () => ({ aliases: [digest], root: "/app", env: { NODE_OPTIONS: "--max-old-space-size=512" } }),
26
+ files: async () => {},
27
+ };
28
+
29
+ test("admission recognizes prepared digest, preserves trust/user settings, covers init containers, and is idempotent", async () => {
30
+ const coverage = k3sCoverage([image]);
31
+ const pod = { metadata: { name: "api", annotations: { other: "value" } }, spec: {
32
+ containers: [{ name: "api", image: digest, env: [{ name: "FOO", value: "bar" }], securityContext: { runAsUser: 1000, readOnlyRootFilesystem: true } },
33
+ { name: "db", image: "postgres:17" }], initContainers: [{ name: "migrate", image: image.reference }],
34
+ } };
35
+ const trusted = apply(pod, podTrustPatch(pod, "ca-bundle"));
36
+ const patches = await coverage.mutate("k8s", trusted, "app", true, false, "admission", dependencies);
37
+ const result = apply(trusted, patches);
38
+ expect(result.spec.containers[0].image).toBe(digest);
39
+ expect(result.spec.containers[0].securityContext).toEqual(pod.spec.containers[0].securityContext);
40
+ expect(result.spec.containers[0].env).toContainEqual({ name: "NODE_OPTIONS", value: "--max-old-space-size=512 --require /spectest/coverage-hook.cjs" });
41
+ expect(result.spec.containers[0].env).toContainEqual({ name: "FOO", value: "bar" });
42
+ expect(result.spec.containers[0].volumeMounts.some((m: any) => m.mountPath === "/spectest/ca-bundle.crt")).toBe(true);
43
+ expect(result.spec.containers[1]).toEqual(trusted.spec.containers[1]);
44
+ const ids = JSON.parse(result.metadata.annotations["spectest.dev/coverage"]).instances;
45
+ expect(new Set(ids).size).toBe(2);
46
+ expect(await coverage.mutate("k8s", result, "app", true, false, "admission", dependencies)).toEqual([]);
47
+ await expect(coverage.mutate("k8s", result, "app", true, false, "another-pod", dependencies)).rejects.toThrow("inherited");
48
+ expect(await coverage.mutate("k8s", trusted, "app", true, true, "ephemeral", dependencies)).toEqual([]);
49
+ const other = structuredClone(pod);
50
+ other.spec.containers[0].image = `images.test/api@sha256:${"b".repeat(64)}`;
51
+ other.spec.initContainers = [];
52
+ expect(await coverage.mutate("k8s", other, "app", true, false, "other", dependencies)).toEqual([]);
53
+ });
54
+
55
+ test("coverage refuses colliding mounts and dynamic NODE_OPTIONS", async () => {
56
+ const coverage = k3sCoverage([image]);
57
+ for (const extra of [{ volumeMounts: [{ name: "user", mountPath: "/spectest" }] }, { env: [{ name: "NODE_OPTIONS", valueFrom: { secretKeyRef: { name: "options", key: "flags" } } }] }]) {
58
+ const pod = { metadata: {}, spec: { containers: [{ name: "api", image: digest, ...extra }] } };
59
+ await expect(coverage.mutate("k8s", pod as Pod, "app", true, false, "id", dependencies)).rejects.toThrow();
60
+ }
61
+ });
62
+
63
+ test("k3s mounts fork-local storage on every node and preserves image collectors when browser coverage is added", () => {
64
+ const environment = defineEnvironment({ name: "coverage", services: { k8s: {
65
+ ...k3s({ nodes: 3, images: [image], ingressDomains: ["app.test"] }),
66
+ coverage: { adapters: [browser()] },
67
+ } } });
68
+ const services = environment.config.services;
69
+ for (const service of Object.values(services)) {
70
+ const volume = service.volumes?.find(v => v.target === "/spectest/pod-coverage");
71
+ expect(volume?.source).toEndWith("/.spectest/coverage/k8s");
72
+ expect(volume?.reachExempt).toBe(true);
73
+ }
74
+ const wire = JSON.parse(JSON.stringify(services.k8s));
75
+ expect(wire.coverage.adapters.map((a: any) => a.adapter)).toEqual(["k3s-containers", "browser"]);
76
+ expect(wire.coverage.adapters[0].images[0].config.env.NODE_OPTIONS).toContain("--require");
77
+ expect(wire.imageBuilds[0].image.coverage).toBeUndefined();
78
+ const changed = defineEnvironment({ name: "coverage", services: { k8s: k3s({ images: [{ ...image, coverage: { adapters: [rust()] } }] }) } });
79
+ expect(JSON.stringify(changed.config)).not.toBe(JSON.stringify(environment.config));
80
+ });
81
+
82
+ test("replica counters are independent, reports survive pod deletion, and capture errors remain visible", async () => {
83
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "k3s-cov-"));
84
+ try {
85
+ const adapter: CoverageAdapter = { name: "lcov", reports: "cumulative", sourceOptions: { sourceRoot: "packages" }, ownsReport: n => n.endsWith(".lcov"),
86
+ async inventory(ctx) { await ctx.exec("inventory-probe"); return ["npm:api@1.0.0", "npm:unused@2.0.0"]; },
87
+ };
88
+ const coverage = k3sCoverage([{ ...image, coverage: { adapters: [adapter] } }]);
89
+ const ids = ["a".repeat(16), "b".repeat(16)];
90
+ for (const id of ids) {
91
+ const dir = path.join(root, ".pods", id);
92
+ await fs.mkdir(dir, { recursive: true });
93
+ await fs.writeFile(path.join(dir, ".instance.json"), JSON.stringify({ id, image: image.reference, container: "api", namespace: "app", root: "/app" }));
94
+ await fs.writeFile(path.join(dir, "app.lcov"), "TN:\nSF:/app/src/api.ts\nDA:1,2\nend_of_record\n");
95
+ }
96
+ let pods = ids.map(id => ({ metadata: { name: `api-${id}`, annotations: { "spectest.dev/coverage": JSON.stringify({ instances: [id] }) } },
97
+ status: { containerStatuses: [{ name: "api", state: { running: {} } }] } }));
98
+ const commands: string[] = [];
99
+ const ctx = { service: "k8s", reportDir: root, signal: AbortSignal.timeout(5000), exec: async (command: string) => {
100
+ commands.push(command); return { stdout: command.startsWith("kubectl get") ? JSON.stringify({ items: pods }) : "", stderr: "" };
101
+ },
102
+ projectFiles: async () => [], readProjectFile: async () => null, writeReport: async () => {} };
103
+ const first = await coverage.adapter().collect!(ctx, true);
104
+ expect(first).toHaveLength(1);
105
+ expect(first[0].error).toBeUndefined();
106
+ expect(first[0].reports).toHaveLength(3);
107
+ expect(first[0].reports.filter(r => r.name.endsWith(".lcov")).every(r => r.sourceOptions?.sourceRoot === "packages")).toBe(true);
108
+ // The consumer recognizes this exact name; per-replica prefixes would hide it.
109
+ expect(first[0].reports.find(r => r.name === "inventory.units")?.content).toBe("npm:api@1.0.0\nnpm:unused@2.0.0");
110
+ expect(commands.filter(command => command.includes("inventory-probe")).sort()).toEqual(ids.map(id => `kubectl exec -n 'app' 'api-${id}' -c 'api' -- sh -c 'inventory-probe'`));
111
+ expect(commands.filter(command => command.startsWith("kubectl get"))).toHaveLength(1);
112
+ // No pod exists any longer. Reports remain on fork-local node storage.
113
+ pods = [];
114
+ commands.length = 0;
115
+ await fs.writeFile(path.join(root, ".pods", ids[1], "app.lcov"), "TN:\nSF:/app/src/api.ts\nDA:1,3\nend_of_record\n");
116
+ const next = await coverage.adapter().collect!(ctx, false);
117
+ expect(next[0].reports[0].content).toBe("TN:\n");
118
+ expect(next[0].reports[1].content).toContain("DA:1,1");
119
+ expect(commands).toEqual([]); // Native collection needs neither API discovery nor exec.
120
+ await fs.writeFile(path.join(root, ".pods", ids[0], "failed.txt"), "lost report");
121
+ expect((await coverage.adapter().collect!(ctx, false))[0].error).toContain("neither");
122
+ } finally { await fs.rm(root, { recursive: true, force: true }); }
123
+ });
124
+
125
+ test("collection bounds parallel targets and shares lazy discovery for command adapters", async () => {
126
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "k3s-cov-parallel-"));
127
+ try {
128
+ let active = 0;
129
+ let peak = 0;
130
+ let release!: () => void;
131
+ const gate = new Promise<void>(resolve => { release = resolve; });
132
+ const adapter: CoverageAdapter = { name: "command", async capture(ctx) {
133
+ peak = Math.max(peak, ++active);
134
+ if (active === 8) release();
135
+ await gate;
136
+ await ctx.exec("capture-probe");
137
+ await ctx.writeReport("app.lcov", "TN:\nSF:/app/main.rs\nDA:1,1\nend_of_record\n");
138
+ active--;
139
+ } };
140
+ const ids = Array.from({ length: 11 }, (_, index) => index.toString(16).padStart(16, "0"));
141
+ for (const id of ids) {
142
+ const dir = path.join(root, ".pods", id);
143
+ await fs.mkdir(dir, { recursive: true });
144
+ await fs.writeFile(path.join(dir, ".instance.json"), JSON.stringify({ id, image: image.reference, container: "api", namespace: "app", root: "/app" }));
145
+ }
146
+ const pods = ids.map(id => ({ metadata: { name: `api-${id}`, annotations: { "spectest.dev/coverage": JSON.stringify({ instances: [id] }) } },
147
+ status: { containerStatuses: [{ name: "api", state: { running: {} } }] } }));
148
+ const commands: string[] = [];
149
+ const ctx = { service: "k8s", reportDir: root, signal: AbortSignal.timeout(5000), exec: async (command: string) => {
150
+ commands.push(command); return { stdout: command.startsWith("kubectl get") ? JSON.stringify({ items: pods }) : "", stderr: "" };
151
+ }, projectFiles: async () => [], readProjectFile: async () => null, writeReport: async () => {} };
152
+ const capture = (await k3sCoverage([{ ...image, coverage: { adapters: [adapter] } }]).adapter().collect!(ctx, false))[0];
153
+ expect(capture.error).toBeUndefined();
154
+ expect(capture.reports.map(report => report.name)).toEqual(ids.map(id => `${id}/app.lcov`));
155
+ expect(active).toBe(0);
156
+ expect(peak).toBe(8);
157
+ expect(commands.filter(command => command.startsWith("kubectl get"))).toHaveLength(1);
158
+ expect(commands.filter(command => command.includes("capture-probe"))).toHaveLength(11);
159
+ } finally { await fs.rm(root, { recursive: true, force: true }); }
160
+ }, 10000);