@specific.dev/spectest 0.127.0 → 0.129.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.
Files changed (46) hide show
  1. package/README.md +54 -0
  2. package/dist/browser.d.ts +12 -5
  3. package/dist/browser.js +13 -13
  4. package/dist/components/k3s-coverage.d.ts +24 -0
  5. package/dist/components/k3s-coverage.js +259 -0
  6. package/dist/components/k3s-trust.d.ts +4 -2
  7. package/dist/components/k3s-trust.js +13 -0
  8. package/dist/components/k3s.js +31 -0
  9. package/dist/coverage/rust.rs +102 -0
  10. package/dist/coverage-bun.d.ts +10 -0
  11. package/dist/coverage-bun.js +32 -0
  12. package/dist/coverage-rust.d.ts +23 -0
  13. package/dist/coverage-rust.js +71 -0
  14. package/dist/coverage.d.ts +11 -5
  15. package/dist/coverage.js +70 -13
  16. package/dist/daemon.js +27 -2
  17. package/dist/harness/component-images.d.ts +1 -0
  18. package/dist/harness/component-images.js +4 -2
  19. package/dist/harness/coverage-target.d.ts +10 -0
  20. package/dist/harness/coverage-target.js +54 -0
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +10 -1
  23. package/dist/recorder.d.ts +29 -2
  24. package/dist/terminal.d.ts +31 -17
  25. package/dist/terminal.js +261 -105
  26. package/package.json +1 -1
  27. package/src/browser-navigation.test.ts +36 -0
  28. package/src/browser.ts +26 -18
  29. package/src/components/k3s-coverage.test.ts +160 -0
  30. package/src/components/k3s-coverage.ts +234 -0
  31. package/src/components/k3s-trust.ts +14 -2
  32. package/src/components/k3s.ts +26 -0
  33. package/src/coverage/rust.rs +102 -0
  34. package/src/coverage-bun.test.ts +103 -0
  35. package/src/coverage-bun.ts +40 -0
  36. package/src/coverage-rust.test.ts +74 -0
  37. package/src/coverage-rust.ts +79 -0
  38. package/src/coverage.test.ts +4 -4
  39. package/src/coverage.ts +74 -13
  40. package/src/daemon.ts +22 -2
  41. package/src/harness/component-images.ts +5 -2
  42. package/src/harness/coverage-target.ts +55 -0
  43. package/src/index.ts +12 -3
  44. package/src/recorder.ts +22 -2
  45. package/src/terminal.test.ts +324 -0
  46. package/src/terminal.ts +269 -119
@@ -0,0 +1,36 @@
1
+ import { expect, test } from "bun:test";
2
+ import { openBrowser } from "./browser.js";
3
+ import { startRecording, stopRecording } from "./recorder.js";
4
+
5
+ const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome"));
6
+
7
+ test.skipIf(!available)("public navigation options control goto/reload and remain visible in recordings", async () => {
8
+ // The document commits immediately but load waits for an image. This
9
+ // distinguishes waitUntil behavior from simply extending a deadline.
10
+ const server = Bun.serve({port:0,hostname:"127.0.0.1", async fetch(request) {
11
+ if (new URL(request.url).pathname === "/slow.png") {
12
+ await Bun.sleep(500);
13
+ return new Response("", {status:404});
14
+ }
15
+ return new Response('<title>Navigation options</title><img src="/slow.png">',
16
+ {headers:{"content-type":"text/html","cache-control":"no-store"}});
17
+ }});
18
+ const browser = await openBrowser({recorder:{sessionId:"navigation",recordStep() {}}});
19
+ startRecording();
20
+ try {
21
+ await browser.goto(server.url.href, {waitUntil:"domcontentloaded",timeout:2000});
22
+ expect(await browser.title()).toBe("Navigation options");
23
+ await expect(browser.reload({waitUntil:"load",timeout:50})).rejects.toThrow(/Timeout/);
24
+ await browser.reload({waitUntil:"load",timeout:2000});
25
+ const events = stopRecording().filter(e => e.kind === "browser");
26
+ expect(events.filter(e => e.action === "goto" || e.action === "reload")).toMatchObject([
27
+ {action:"goto",waitUntil:"domcontentloaded",timeout:2000},
28
+ {action:"reload",waitUntil:"load",timeout:50,error:expect.any(String)},
29
+ {action:"reload",waitUntil:"load",timeout:2000},
30
+ ]);
31
+ } finally {
32
+ stopRecording();
33
+ await browser.close();
34
+ server.stop(true);
35
+ }
36
+ }, 15000);
package/src/browser.ts CHANGED
@@ -208,6 +208,12 @@ export interface Mouse {
208
208
  wheel(dx: number, dy: number): Promise<void>;
209
209
  }
210
210
 
211
+ export interface NavigationOptions {
212
+ /** Playwright navigation timeout in milliseconds; 0 disables the deadline. */
213
+ timeout?: number;
214
+ waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit";
215
+ }
216
+
211
217
  /**
212
218
  * Headless browser session — Playwright `Page`-shaped. Select elements with
213
219
  * the `getBy*`/`locator` roots (returning a {@link Locator}) and act on them;
@@ -220,14 +226,14 @@ export interface Browser {
220
226
  url(): string;
221
227
  /** Current page `<title>` (async, like Playwright's `page.title()`). */
222
228
  title(): Promise<string>;
223
- /** Navigate to a URL; resolves when the main frame's load completes. */
224
- goto(url: string): Promise<void>;
229
+ /** Navigate to a URL; waits for the main frame load unless waitUntil is supplied. */
230
+ goto(url: string, options?: NavigationOptions): Promise<void>;
225
231
  /** Navigate back in session history. */
226
- goBack(): Promise<void>;
232
+ goBack(options?: NavigationOptions): Promise<void>;
227
233
  /** Navigate forward in session history. */
228
- goForward(): Promise<void>;
234
+ goForward(options?: NavigationOptions): Promise<void>;
229
235
  /** Reload the current page. */
230
- reload(): Promise<void>;
236
+ reload(options?: NavigationOptions): Promise<void>;
231
237
 
232
238
  readonly keyboard: Keyboard;
233
239
  readonly mouse: Mouse;
@@ -1995,11 +2001,11 @@ function buildBackend(
1995
2001
  keyboard,
1996
2002
  mouse,
1997
2003
  touchscreen,
1998
- goto(url) {
2004
+ goto(url, options) {
1999
2005
  recorder?.noteNavigation?.(url);
2000
- return instrumented("goto", { url }, async () => {
2006
+ return instrumented("goto", { url, ...options }, async () => {
2001
2007
  try {
2002
- await holder.page.goto(url, { waitUntil: "load" });
2008
+ await holder.page.goto(url, { waitUntil: "load", ...options });
2003
2009
  } catch (err) {
2004
2010
  // Restored-renderer DNS bug (see `rebuildView`): only when the
2005
2011
  // view is persistent (so it may have lived through a snapshot
@@ -2009,23 +2015,23 @@ function buildBackend(
2009
2015
  if (!buildOpts.persistent || !isNameNotResolved(err)) throw err;
2010
2016
  if (!(await daemonResolves(url))) throw err;
2011
2017
  await rebuildView(holder);
2012
- await holder.page.goto(url, { waitUntil: "load" });
2018
+ await holder.page.goto(url, { waitUntil: "load", ...options });
2013
2019
  }
2014
2020
  });
2015
2021
  },
2016
- goBack() {
2017
- return instrumented("goBack", {}, async () => {
2018
- await holder.page.goBack();
2022
+ goBack(options) {
2023
+ return instrumented("goBack", { ...options }, async () => {
2024
+ await holder.page.goBack(options);
2019
2025
  });
2020
2026
  },
2021
- goForward() {
2022
- return instrumented("goForward", {}, async () => {
2023
- await holder.page.goForward();
2027
+ goForward(options) {
2028
+ return instrumented("goForward", { ...options }, async () => {
2029
+ await holder.page.goForward(options);
2024
2030
  });
2025
2031
  },
2026
- reload() {
2027
- return instrumented("reload", {}, async () => {
2028
- await holder.page.reload();
2032
+ reload(options) {
2033
+ return instrumented("reload", { ...options }, async () => {
2034
+ await holder.page.reload(options);
2029
2035
  });
2030
2036
  },
2031
2037
 
@@ -2325,6 +2331,8 @@ function buildBackend(
2325
2331
  }
2326
2332
 
2327
2333
  export interface RecordableFields {
2334
+ timeout: number;
2335
+ waitUntil: NonNullable<NavigationOptions["waitUntil"]>;
2328
2336
  target: RecordedBrowserLocatorPart[];
2329
2337
  destinationTarget: RecordedBrowserLocatorPart[];
2330
2338
  url: string;
@@ -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);
@@ -0,0 +1,234 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { applyCoverageAdapters, coverageAdapters, type CoverageAdapter, type ServiceCoverage } from "../coverage.js";
5
+ import { coverageHostDir, COVERAGE_MAX_BYTES_PER_SERVICE, type ServiceCoverageCapture } from "../harness/coverage.js";
6
+ import { captureCoverageTarget } from "../harness/coverage-target.js";
7
+ import { canonicalImageReference, type KubernetesImage } from "../harness/component-images.js";
8
+ import type { ServiceConfig } from "../index.js";
9
+ import { WORKSPACE } from "../project-files.js";
10
+ import type { Patch, Pod } from "./k3s-trust.js";
11
+
12
+ const MOUNT = "/spectest/pod-coverage";
13
+ const ANNOTATION = "spectest.dev/coverage";
14
+ const hash = (value: string) => createHash("sha256").update(value).digest("hex").slice(0, 16);
15
+ const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
16
+ interface ImageInfo { aliases: string[]; root: string; env: Record<string, string> }
17
+ interface Instance { id: string; image: string; container: string; root: string; namespace: string }
18
+ interface Container {
19
+ name: string; image: string; workingDir?: string;
20
+ env?: { name: string; value?: string; valueFrom?: unknown }[];
21
+ volumeMounts?: { name: string; mountPath: string; [key: string]: unknown }[];
22
+ }
23
+ interface CoveragePod {
24
+ metadata?: { name?: string; annotations?: Record<string, string> };
25
+ spec?: { containers?: Container[]; initContainers?: Container[]; volumes?: { name: string; [key: string]: unknown }[] };
26
+ status?: { containerStatuses?: { name: string; state?: { running?: unknown } }[]; initContainerStatuses?: { name: string; state?: { running?: unknown } }[] };
27
+ }
28
+ export interface K3sCoverageDependencies {
29
+ image(reference: string): Promise<ImageInfo>;
30
+ files(namespace: string, name: string, data: Record<string, string>): Promise<void>;
31
+ }
32
+
33
+ /** One component-owned bridge; runtime adapters remain unaware of Kubernetes. */
34
+ export function k3sCoverage(images: readonly KubernetesImage[]) {
35
+ const declarations = images.flatMap(image => typeof image === "string" || image.coverage === undefined ? [] : [{
36
+ reference: canonicalImageReference(image.reference), coverage: image.coverage,
37
+ }]);
38
+ for (const image of declarations) {
39
+ const adapters = coverageAdapters(image.coverage);
40
+ if (adapters.some(adapter => adapter.load || adapter.collect)) throw new Error("k3s image coverage accepts container adapters; put browser coverage on the cluster service");
41
+ applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage });
42
+ }
43
+ const metadata = new Map<string, ImageInfo>();
44
+ const service = (cluster: string, reference: string) => `${cluster}/image-${hash(reference)}`;
45
+ return {
46
+ enabled: declarations.length > 0,
47
+ volume(cluster: string) {
48
+ return { source: coverageHostDir(WORKSPACE, cluster), target: MOUNT, reachExempt: true };
49
+ },
50
+ async mutate(cluster: string, podInput: Pod, namespace: string, dryRun: boolean, ephemeral: boolean, uid: string, deps: K3sCoverageDependencies): Promise<Patch[]> {
51
+ if (!declarations.length || ephemeral) return [];
52
+ const pod = podInput as CoveragePod;
53
+ if (!pod.spec) return [];
54
+ const existing = pod.metadata?.annotations?.[ANNOTATION];
55
+ if (existing) {
56
+ if (JSON.parse(existing).admission === uid) return [];
57
+ throw new Error("remove inherited spectest.dev/coverage annotations before creating another Pod");
58
+ }
59
+ const patch: Patch[] = [];
60
+ const volumes = [...(pod.spec.volumes ?? [])];
61
+ const instances: Instance[] = [];
62
+ const namedVolume = (stem: string, volume: Record<string, unknown>) => {
63
+ let name = stem;
64
+ for (let i = 1; volumes.some(v => v.name === name); i++) name = `${stem}-${i}`;
65
+ volumes.push({ name, ...volume }); return name;
66
+ };
67
+ for (const group of ["initContainers", "containers"] as const) {
68
+ for (const [index, container] of (pod.spec[group] ?? []).entries()) {
69
+ const requested = canonicalImageReference(container.image);
70
+ let declaration = declarations.find(image => image.reference === requested);
71
+ // Tangent deploys the prepared image by digest. Resolve aliases from
72
+ // CRI's actual image record, never by stripping a repository tag.
73
+ if (!declaration && requested.includes("@")) {
74
+ for (const candidate of declarations) {
75
+ let info = metadata.get(candidate.reference);
76
+ if (!info) metadata.set(candidate.reference, info = await deps.image(candidate.reference));
77
+ if (info.aliases.includes(requested)) { declaration = candidate; break; }
78
+ }
79
+ }
80
+ if (!declaration) continue;
81
+ let info = metadata.get(declaration.reference);
82
+ if (!info) metadata.set(declaration.reference, info = await deps.image(declaration.reference));
83
+ const id = hash(`${uid}/${group}/${container.name}`);
84
+ const name = service(cluster, declaration.reference);
85
+ const env = { ...info.env };
86
+ for (const entry of container.env ?? []) if (entry.value !== undefined) env[entry.name] = entry.value;
87
+ const configured = applyCoverageAdapters<ServiceConfig>(name, { image: { type: "registry", reference: container.image }, env, coverage: declaration.coverage });
88
+ if (Object.keys(configured).some(key => !["image", "env", "coverage", "files"].includes(key))) {
89
+ throw new Error("Kubernetes coverage adapters may inject env and files only; configure the workload's command and mounts in its manifest");
90
+ }
91
+ const changed = Object.entries(configured.env ?? {}).filter(([key, value]) => env[key] !== value);
92
+ for (const [key] of changed) if (container.env?.some(e => e.name === key && e.valueFrom)) throw new Error(`cannot inject coverage into dynamic ${key}`);
93
+ const mounts = [...(container.volumeMounts ?? [])];
94
+ const addMount = (mountPath: string, mount: Record<string, unknown>) => {
95
+ if (mounts.some(m => mountPath === m.mountPath || mountPath.startsWith(m.mountPath.replace(/\/$/, "") + "/"))) throw new Error(`coverage mount conflicts with ${mountPath}`);
96
+ mounts.push({ mountPath, ...mount } as typeof mounts[number]);
97
+ };
98
+ const volume = namedVolume(`spectest-cov-${id}`, { hostPath: { path: `${MOUNT}/.pods/${id}`, type: "Directory" } });
99
+ addMount("/spectest/coverage", { name: volume });
100
+ const files = configured.files ?? [];
101
+ if (files.some(file => file.user !== undefined || file.group !== undefined || (file.mode !== undefined && !["444", "0444", "644", "0644"].includes(file.mode)))) {
102
+ throw new Error("Kubernetes coverage hook files must be world-readable and need no custom ownership");
103
+ }
104
+ if (files.length) {
105
+ const data = Object.fromEntries(files.map((file, index) => [`file${index}`, file.content]));
106
+ const configMap = `spectest-coverage-${hash(JSON.stringify(data))}`;
107
+ if (!dryRun) await deps.files(namespace, configMap, data);
108
+ const fileVolume = namedVolume(`spectest-hooks-${id}`, { configMap: { name: configMap, defaultMode: 0o444 } });
109
+ files.forEach((file, index) => addMount(file.path, { name: fileVolume, subPath: `file${index}`, readOnly: true }));
110
+ }
111
+ const instance: Instance = { id, image: declaration.reference, namespace, container: container.name, root: container.workingDir || info.root || "/" };
112
+ if (!dryRun) {
113
+ const dir = path.join(coverageHostDir(WORKSPACE, cluster), ".pods", id);
114
+ await fs.mkdir(dir, { recursive: true, mode: 0o777 });
115
+ await fs.chmod(dir, 0o777);
116
+ await fs.writeFile(path.join(dir, ".instance.json"), JSON.stringify(instance));
117
+ }
118
+ instances.push(instance);
119
+ patch.push({ op: "add", path: `/spec/${group}/${index}/env`, value: [
120
+ ...(container.env ?? []).filter(e => !changed.some(([key]) => key === e.name)),
121
+ ...changed.map(([name, value]) => ({ name, value })),
122
+ ] }, { op: "add", path: `/spec/${group}/${index}/volumeMounts`, value: mounts });
123
+ }
124
+ }
125
+ if (!instances.length) return [];
126
+ patch.push({ op: "add", path: "/spec/volumes", value: volumes });
127
+ patch.push({ op: "add", path: "/metadata/annotations", value: { ...pod.metadata?.annotations,
128
+ [ANNOTATION]: JSON.stringify({ admission: uid, instances: instances.map(i => i.id) }),
129
+ } });
130
+ return patch;
131
+ },
132
+ adapter(): CoverageAdapter {
133
+ return {
134
+ name: "k3s-containers",
135
+ // Include nested policy AND injected hooks in the warm identity. An
136
+ // adapter's functions would otherwise disappear from JSON.stringify.
137
+ toJSON() { return { adapter: "k3s-containers", images: declarations.map(image => ({
138
+ reference: image.reference,
139
+ config: applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage }),
140
+ })) }; },
141
+ async collect(ctx, baseline) {
142
+ const root = path.join(ctx.reportDir, ".pods");
143
+ let entries: string[];
144
+ try { entries = await fs.readdir(root); } catch (error: any) { if (error.code !== "ENOENT") throw error; entries = []; }
145
+ // Native adapters flush through the shared Unix sockets, including
146
+ // after Pod deletion. Only inventory or command adapters need the API.
147
+ let pods: Promise<CoveragePod[]> | undefined;
148
+ const findPod = async (id: string) => {
149
+ pods ??= ctx.exec("kubectl get pods -A -o json --request-timeout=10s")
150
+ .then(result => JSON.parse(result.stdout).items as CoveragePod[]);
151
+ return (await pods).find(p => {
152
+ const ids = p.metadata?.annotations?.[ANNOTATION];
153
+ return ids && (JSON.parse(ids).instances as string[]).includes(id);
154
+ });
155
+ };
156
+ const captures = new Map<string, ServiceCoverageCapture>();
157
+ const inventories = new Map<string, Set<string>>();
158
+ for (const declaration of declarations) captures.set(declaration.reference, { service: service(ctx.service, declaration.reference), reports: [] });
159
+ const collectInstance = async (id: string) => {
160
+ ctx.signal.throwIfAborted();
161
+ if (!/^[a-f0-9]{16}$/.test(id)) throw new Error("invalid coverage instance directory");
162
+ const dir = path.join(root, id);
163
+ const instance = JSON.parse(await fs.readFile(path.join(dir, ".instance.json"), "utf8")) as Instance;
164
+ const declaration = declarations.find(d => d.reference === instance.image);
165
+ if (!declaration) throw new Error(`coverage image no longer declared: ${instance.image}`);
166
+ const adapters = coverageAdapters(declaration.coverage);
167
+ const livePod = async () => {
168
+ const pod = await findPod(id);
169
+ const statuses = [...(pod?.status?.containerStatuses ?? []), ...(pod?.status?.initContainerStatuses ?? [])];
170
+ return statuses.some(status => status.name === instance.container && status.state?.running) ? pod : undefined;
171
+ };
172
+ for (const adapter of adapters) {
173
+ if (adapter.name === "node") {
174
+ try { await fs.access(path.join(dir, `.attached-${adapter.name}`)); }
175
+ catch { throw new Error(`coverage hook never attached to ${instance.namespace}/${instance.container} (${adapter.name})`); }
176
+ }
177
+ }
178
+ const captured = await captureCoverageTarget({
179
+ coverage: declaration.coverage, root: instance.root, baseline,
180
+ inventoryAvailable: baseline && adapters.some(a => a.inventory) ? !!await livePod() : false,
181
+ counterKey: `${service(ctx.service, instance.image)}/${id}`,
182
+ context: { ...ctx, service: service(ctx.service, instance.image), reportDir: dir,
183
+ exec: async command => {
184
+ const pod = await livePod();
185
+ if (!pod?.metadata?.name) throw new Error(`coverage command requires live container ${instance.container}`);
186
+ return ctx.exec(`kubectl exec -n ${quote(instance.namespace)} ${quote(pod.metadata.name)} -c ${quote(instance.container)} -- sh -c ${quote(command)}`);
187
+ },
188
+ },
189
+ });
190
+ return { id, instance, captured };
191
+ };
192
+ // Bound simultaneous LLVM exports and API execs. Drain all workers
193
+ // even on error so no collector can race the following VM snapshot.
194
+ const pending = entries.sort();
195
+ const results = new Map<string, Awaited<ReturnType<typeof collectInstance>>>();
196
+ let cursor = 0;
197
+ const workers = await Promise.allSettled(Array.from({ length: Math.min(8, pending.length) }, async () => {
198
+ while (cursor < pending.length) {
199
+ const id = pending[cursor++];
200
+ results.set(id, await collectInstance(id));
201
+ }
202
+ }));
203
+ const failure = workers.find(worker => worker.status === "rejected");
204
+ if (failure?.status === "rejected") throw failure.reason;
205
+ for (const id of pending) {
206
+ const { instance, captured } = results.get(id)!;
207
+ const aggregate = captures.get(instance.image)!;
208
+ if (captured.error) aggregate.error = [aggregate.error, `${instance.namespace}/${instance.container}: ${captured.error}`].filter(Boolean).join("; ");
209
+ if (aggregate.root && aggregate.root !== instance.root) {
210
+ aggregate.error = "coverage containers from one image have different working directories; use a common workingDir for this image's covered containers";
211
+ }
212
+ aggregate.root = instance.root;
213
+ for (const report of captured.reports) {
214
+ if (report.name === "inventory.units") {
215
+ let units = inventories.get(instance.image);
216
+ if (!units) inventories.set(instance.image, units = new Set());
217
+ for (const unit of report.content.split("\n")) if (unit) units.add(unit);
218
+ } else aggregate.reports.push({ ...report, name: `${id}/${report.name}` });
219
+ }
220
+ }
221
+ for (const [image, units] of inventories) captures.get(image)!.reports.push({ name: "inventory.units", content: [...units].sort().join("\n") });
222
+ for (const capture of captures.values()) {
223
+ if (!capture.reports.length && !capture.error) capture.reports.push({ name: "empty.lcov", content: "TN:\n" });
224
+ if (capture.reports.reduce((bytes, report) => bytes + Buffer.byteLength(report.content), 0) > COVERAGE_MAX_BYTES_PER_SERVICE) {
225
+ capture.reports = [];
226
+ capture.error = "Kubernetes image coverage exceeds the per-service report size limit";
227
+ }
228
+ }
229
+ return [...captures.values()];
230
+ },
231
+ } as CoverageAdapter;
232
+ },
233
+ };
234
+ }
@@ -14,7 +14,7 @@ interface Container {
14
14
  env?: { name: string; value?: string; valueFrom?: unknown }[];
15
15
  volumeMounts?: { name: string; mountPath: string; subPath?: string; readOnly?: boolean }[];
16
16
  }
17
- interface Pod {
17
+ export interface Pod {
18
18
  metadata?: { annotations?: Record<string, string> };
19
19
  spec?: {
20
20
  os?: { name: string };
@@ -22,7 +22,7 @@ interface Pod {
22
22
  containers?: Container[]; initContainers?: Container[]; ephemeralContainers?: Container[];
23
23
  };
24
24
  }
25
- interface Patch { op: "add"; path: string; value: unknown }
25
+ export interface Patch { op: "add"; path: string; value: unknown }
26
26
  export const TRUST_OPT_OUT = "spectest.dev/inject-ca";
27
27
 
28
28
  export function podTrustPatch(pod: Pod, configMap: string, ephemeral = false): Patch[] {
@@ -67,6 +67,8 @@ export interface TrustDependencies {
67
67
  /** Ensure this immutable ConfigMap exists in the requesting namespace. */
68
68
  ensureBundle(namespace: string, name: string, bundle: string): Promise<void>;
69
69
  apply(configuration: unknown): Promise<void>;
70
+ /** Component-owned pod instrumentation runs after trust patching. */
71
+ mutatePod?(pod: Pod, namespace: string, dryRun: boolean, ephemeral: boolean, uid: string): Promise<Patch[]>;
70
72
  }
71
73
  const servers = new Map<string, Server>();
72
74
 
@@ -91,6 +93,16 @@ export async function installPodTrust(options: {
91
93
  res.writeHead(400).end("invalid Pod admission request"); return;
92
94
  }
93
95
  const patch = podTrustPatch(request.object, configMap, request.subResource === "ephemeralcontainers");
96
+ if (deps.mutatePod) {
97
+ const pod = structuredClone(request.object);
98
+ for (const operation of patch) {
99
+ const keys = operation.path.slice(1).split("/");
100
+ const key = keys.pop()!;
101
+ const parent = keys.reduce((value: any, key) => value[key], pod as any);
102
+ if (key === "-") parent.push(operation.value); else parent[key] = operation.value;
103
+ }
104
+ patch.push(...await deps.mutatePod(pod, request.namespace, !!request.dryRun, request.subResource === "ephemeralcontainers", request.uid));
105
+ }
94
106
  // Dry runs return the same patch but perform no API writes.
95
107
  if (patch.length && !request.dryRun) await deps.ensureBundle(request.namespace, configMap, options.bundle);
96
108
  res.setHeader("Content-Type", "application/json");
@@ -1,6 +1,7 @@
1
1
  import { isIP } from "node:net";
2
2
  import { waitForK3sNodes } from "./k3s-nodes.js";
3
3
  import { installPodTrust } from "./k3s-trust.js";
4
+ import { k3sCoverage } from "./k3s-coverage.js";
4
5
  import { waitForAll } from "../harness/concurrency.js";
5
6
  import { CONTENT_BLOBS, REGISTRY_CONTENT, REGISTRY_DATA, REGISTRY_IMAGE, registryDirectory, seedRegistry } from "../harness/registry-seed.js";
6
7
  import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
@@ -1969,6 +1970,7 @@ export function k3s(opts: K3sOptions = {}) {
1969
1970
  function createK3sCluster(opts: K3sOptions, count: number) {
1970
1971
  const version = opts.version ?? DEFAULT_K3S_VERSION;
1971
1972
  const images = kubernetesImages(opts.images ?? []);
1973
+ const coverage = k3sCoverage(opts.images ?? []);
1972
1974
  const extra = opts.extraArgs ?? [];
1973
1975
  assertUsableExtraArgs(extra);
1974
1976
  const registryEnabled = opts.registry !== false;
@@ -2132,6 +2134,7 @@ function createK3sCluster(opts: K3sOptions, count: number) {
2132
2134
  volumes: [{ name: `spectest-k3s-${cluster}-join`, target: JOIN_DIRECTORY }, ...(sticky
2133
2135
  ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
2134
2136
  : [{ target: CLUSTER_STORE_TARGET }]),
2137
+ ...(coverage.enabled ? [coverage.volume(cluster)] : []),
2135
2138
  ...(server && publishBuilds.length ? [
2136
2139
  { source: registryDirectory(SELF_SERVICE_TOKEN), target: REGISTRY_DATA },
2137
2140
  { source: contentDirectory, target: REGISTRY_CONTENT, readOnly: true },
@@ -2140,6 +2143,7 @@ function createK3sCluster(opts: K3sOptions, count: number) {
2140
2143
  files: [
2141
2144
  ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
2142
2145
  ],
2146
+ ...(server && coverage.enabled ? { coverage: { adapters: [coverage.adapter()] } } : {}),
2143
2147
  // Agents start before the server and wait for its join token. The group
2144
2148
  // setup below gates readiness on every actual Kubernetes Node, including
2145
2149
  // the server, rather than treating container startup as cluster readiness.
@@ -2167,11 +2171,33 @@ function createK3sCluster(opts: K3sOptions, count: number) {
2167
2171
  const nodes = nodeNames(name);
2168
2172
  if (images.builds.length && !runtime) throw new Error(`k3s(${name}): prepared image inputs are missing`);
2169
2173
  const admission = (async () => {
2174
+ if (coverage.enabled && (!runtime?.caBundle || !caPresent())) throw new Error(`k3s(${name}): coverage injection requires the environment CA`);
2170
2175
  if (runtime?.caBundle && caPresent()) {
2171
2176
  const { cert, key } = await issueIngressCert([runtime.gateway]);
2172
2177
  await installPodTrust({ name, gateway: runtime.gateway, cert, key,
2173
2178
  ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
2174
2179
  }, {
2180
+ mutatePod: (pod, namespace, dryRun, ephemeral, uid) => coverage.mutate(name, pod, namespace, dryRun, ephemeral, uid, {
2181
+ async image(reference) {
2182
+ const result = await exec(name, ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock", "inspecti", reference]);
2183
+ if (result.exitCode !== 0) throw new Error(`coverage image metadata: ${result.stderr}`);
2184
+ const record = JSON.parse(result.stdout);
2185
+ const config = record.info?.imageSpec?.config;
2186
+ if (!config) throw new Error(`coverage image ${reference} has no OCI configuration`);
2187
+ return { aliases: record.status?.repoDigests ?? [], root: config.WorkingDir || "/",
2188
+ env: Object.fromEntries((config.Env ?? []).map((entry: string) => { const split = entry.indexOf("="); return [entry.slice(0, split), entry.slice(split + 1)]; })),
2189
+ };
2190
+ },
2191
+ async files(namespace, name, data) {
2192
+ try {
2193
+ await helpers.client.core.createNamespacedConfigMap({ namespace, body: { metadata: { name, namespace }, immutable: true, data } });
2194
+ } catch (error) {
2195
+ const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name })).unwrap();
2196
+ if (!existing.immutable || Object.keys(existing.data ?? {}).length !== Object.keys(data).length ||
2197
+ Object.entries(data).some(([key, value]) => existing.data?.[key] !== value)) throw error;
2198
+ }
2199
+ },
2200
+ }),
2175
2201
  apply: async configuration => { await helpers.apply(JSON.stringify(configuration)); },
2176
2202
  ensureBundle: async (namespace, configMap, bundle) => {
2177
2203
  try {