@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
package/README.md CHANGED
@@ -24,6 +24,60 @@ work; do not replace readiness with sleeps or arbitrary `.first()` selections.
24
24
  SDK 0.123.0 uses native Playwright assertions while retaining Spectest's API and
25
25
  recording. Ambiguous single-element selectors fail immediately; make them unique.
26
26
 
27
+ For terminal readiness, use `terminal.waitFor` directly. It retries the condition
28
+ and throws on timeout, so neither an outer `ctx.wait` nor an extra assertion is
29
+ needed to check that text appears:
30
+
31
+ ```ts
32
+ await terminal.waitFor("Rack is initialized", /Current rack status:\s*Initialized/, {
33
+ timeoutMs: 35 * 60_000,
34
+ intervalMs: 5_000,
35
+ failOn: [
36
+ { description: "Setup was rejected", matcher: "Start Rack Setup Failed" },
37
+ { description: "Initialization failed", matcher: "Initialization Failed" },
38
+ { description: "Initialization panicked", matcher: "Initialization Panicked" },
39
+ ],
40
+ });
41
+ await terminal.press("Ctrl+C"); // Separate cleanup after the condition succeeds.
42
+ ```
43
+
44
+ SDK 0.129.0 records the string/regex matcher (or identifies a custom predicate),
45
+ timeout, and start/end positions for this wait. The dashboard shows its condition
46
+ and outcome and replays up to the result. Later inputs belong to their own steps;
47
+ the full terminal session can be opened separately. `ctx.wait` remains useful
48
+ when a retry must repeat actions, rather than just observe terminal state.
49
+
50
+ Terminal waits record matched, timed-out, failure-condition, predicate-error,
51
+ process-exit and cancelled outcomes. `failOn` checks the rendered screen before
52
+ checking success; string and regex conditions are recorded explicitly. For a
53
+ custom predicate, provide `condition: "Expected sled has a valid bootstrap IPv6 address"`
54
+ to explain the actual check separately from its step title. Async predicates are
55
+ awaited, and their rejection is recorded. A final matching screen succeeds even
56
+ if the process has exited; otherwise exit fails the wait after final output is
57
+ drained. No `Promise.race` with `terminal.exited` is needed.
58
+
59
+ Pass `signal: controller.signal` to cancel a wait without closing its terminal.
60
+ Cancellation stops SDK polling; it cannot forcibly stop work started inside a
61
+ custom predicate. `terminal.close({ timeoutMs: 10_000 })` cancels pending waits,
62
+ sends EOF, escalates to a kill, and bounds cleanup including output drain. The
63
+ default close deadline is 10 seconds; repeated calls share the same result or
64
+ failure. `terminal.exited` records the exit when observed after completion, not
65
+ when its promise is first read. A stalled output drain fails after 5 seconds
66
+ instead of hanging indefinitely. Test code should preserve its original failure
67
+ if diagnostic or cleanup operations also fail.
68
+
69
+ Browser navigation accepts public Playwright-style options, without a backend
70
+ cast or `pageOp` call:
71
+
72
+ ```ts
73
+ await browser.goto(consoleUrl, { timeout: 120_000, waitUntil: "domcontentloaded" });
74
+ await browser.reload({ timeout: 120_000 });
75
+ ```
76
+
77
+ These options also apply to `goBack` and `goForward`. Navigation deadlines do not
78
+ cancel a surrounding `ctx.poll`, and retrying a navigation after a visible error
79
+ is a recovery action that should remain visible in the test results.
80
+
27
81
  Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
28
82
  `pubsub`/`pubsubClient` exports and `/components/gcp` entry point are removed.
29
83
  Use a local service definition or `serviceGroup()` wrapper; the repository’s
package/dist/browser.d.ts CHANGED
@@ -124,6 +124,11 @@ export interface Mouse {
124
124
  /** Wheel-scroll by a pixel delta. */
125
125
  wheel(dx: number, dy: number): Promise<void>;
126
126
  }
127
+ export interface NavigationOptions {
128
+ /** Playwright navigation timeout in milliseconds; 0 disables the deadline. */
129
+ timeout?: number;
130
+ waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit";
131
+ }
127
132
  /**
128
133
  * Headless browser session — Playwright `Page`-shaped. Select elements with
129
134
  * the `getBy*`/`locator` roots (returning a {@link Locator}) and act on them;
@@ -136,14 +141,14 @@ export interface Browser {
136
141
  url(): string;
137
142
  /** Current page `<title>` (async, like Playwright's `page.title()`). */
138
143
  title(): Promise<string>;
139
- /** Navigate to a URL; resolves when the main frame's load completes. */
140
- goto(url: string): Promise<void>;
144
+ /** Navigate to a URL; waits for the main frame load unless waitUntil is supplied. */
145
+ goto(url: string, options?: NavigationOptions): Promise<void>;
141
146
  /** Navigate back in session history. */
142
- goBack(): Promise<void>;
147
+ goBack(options?: NavigationOptions): Promise<void>;
143
148
  /** Navigate forward in session history. */
144
- goForward(): Promise<void>;
149
+ goForward(options?: NavigationOptions): Promise<void>;
145
150
  /** Reload the current page. */
146
- reload(): Promise<void>;
151
+ reload(options?: NavigationOptions): Promise<void>;
147
152
  readonly keyboard: Keyboard;
148
153
  readonly mouse: Mouse;
149
154
  /** Root CSS query. */
@@ -363,6 +368,8 @@ export declare function acquirePersistentDesktop(endpoint: string, windowIndex:
363
368
  desktopChrome?: DesktopChrome;
364
369
  }>;
365
370
  export interface RecordableFields {
371
+ timeout: number;
372
+ waitUntil: NonNullable<NavigationOptions["waitUntil"]>;
366
373
  target: RecordedBrowserLocatorPart[];
367
374
  destinationTarget: RecordedBrowserLocatorPart[];
368
375
  url: string;
package/dist/browser.js CHANGED
@@ -1491,11 +1491,11 @@ function buildBackend(holder, recorder, buildOpts) {
1491
1491
  keyboard,
1492
1492
  mouse,
1493
1493
  touchscreen,
1494
- goto(url) {
1494
+ goto(url, options) {
1495
1495
  recorder?.noteNavigation?.(url);
1496
- return instrumented("goto", { url }, async () => {
1496
+ return instrumented("goto", { url, ...options }, async () => {
1497
1497
  try {
1498
- await holder.page.goto(url, { waitUntil: "load" });
1498
+ await holder.page.goto(url, { waitUntil: "load", ...options });
1499
1499
  }
1500
1500
  catch (err) {
1501
1501
  // Restored-renderer DNS bug (see `rebuildView`): only when the
@@ -1508,23 +1508,23 @@ function buildBackend(holder, recorder, buildOpts) {
1508
1508
  if (!(await daemonResolves(url)))
1509
1509
  throw err;
1510
1510
  await rebuildView(holder);
1511
- await holder.page.goto(url, { waitUntil: "load" });
1511
+ await holder.page.goto(url, { waitUntil: "load", ...options });
1512
1512
  }
1513
1513
  });
1514
1514
  },
1515
- goBack() {
1516
- return instrumented("goBack", {}, async () => {
1517
- await holder.page.goBack();
1515
+ goBack(options) {
1516
+ return instrumented("goBack", { ...options }, async () => {
1517
+ await holder.page.goBack(options);
1518
1518
  });
1519
1519
  },
1520
- goForward() {
1521
- return instrumented("goForward", {}, async () => {
1522
- await holder.page.goForward();
1520
+ goForward(options) {
1521
+ return instrumented("goForward", { ...options }, async () => {
1522
+ await holder.page.goForward(options);
1523
1523
  });
1524
1524
  },
1525
- reload() {
1526
- return instrumented("reload", {}, async () => {
1527
- await holder.page.reload();
1525
+ reload(options) {
1526
+ return instrumented("reload", { ...options }, async () => {
1527
+ await holder.page.reload(options);
1528
1528
  });
1529
1529
  },
1530
1530
  locator: (css) => makeLocator(backend, strategy, { steps: [{ m: "locator", args: [css] }] }),
@@ -0,0 +1,24 @@
1
+ import { type CoverageAdapter } from "../coverage.js";
2
+ import { type KubernetesImage } from "../harness/component-images.js";
3
+ import type { Patch, Pod } from "./k3s-trust.js";
4
+ interface ImageInfo {
5
+ aliases: string[];
6
+ root: string;
7
+ env: Record<string, string>;
8
+ }
9
+ export interface K3sCoverageDependencies {
10
+ image(reference: string): Promise<ImageInfo>;
11
+ files(namespace: string, name: string, data: Record<string, string>): Promise<void>;
12
+ }
13
+ /** One component-owned bridge; runtime adapters remain unaware of Kubernetes. */
14
+ export declare function k3sCoverage(images: readonly KubernetesImage[]): {
15
+ enabled: boolean;
16
+ volume(cluster: string): {
17
+ source: string;
18
+ target: string;
19
+ reachExempt: boolean;
20
+ };
21
+ mutate(cluster: string, podInput: Pod, namespace: string, dryRun: boolean, ephemeral: boolean, uid: string, deps: K3sCoverageDependencies): Promise<Patch[]>;
22
+ adapter(): CoverageAdapter;
23
+ };
24
+ export {};
@@ -0,0 +1,259 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { applyCoverageAdapters, coverageAdapters } from "../coverage.js";
5
+ import { coverageHostDir, COVERAGE_MAX_BYTES_PER_SERVICE } from "../harness/coverage.js";
6
+ import { captureCoverageTarget } from "../harness/coverage-target.js";
7
+ import { canonicalImageReference } from "../harness/component-images.js";
8
+ import { WORKSPACE } from "../project-files.js";
9
+ const MOUNT = "/spectest/pod-coverage";
10
+ const ANNOTATION = "spectest.dev/coverage";
11
+ const hash = (value) => createHash("sha256").update(value).digest("hex").slice(0, 16);
12
+ const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
13
+ /** One component-owned bridge; runtime adapters remain unaware of Kubernetes. */
14
+ export function k3sCoverage(images) {
15
+ const declarations = images.flatMap(image => typeof image === "string" || image.coverage === undefined ? [] : [{
16
+ reference: canonicalImageReference(image.reference), coverage: image.coverage,
17
+ }]);
18
+ for (const image of declarations) {
19
+ const adapters = coverageAdapters(image.coverage);
20
+ if (adapters.some(adapter => adapter.load || adapter.collect))
21
+ throw new Error("k3s image coverage accepts container adapters; put browser coverage on the cluster service");
22
+ applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage });
23
+ }
24
+ const metadata = new Map();
25
+ const service = (cluster, reference) => `${cluster}/image-${hash(reference)}`;
26
+ return {
27
+ enabled: declarations.length > 0,
28
+ volume(cluster) {
29
+ return { source: coverageHostDir(WORKSPACE, cluster), target: MOUNT, reachExempt: true };
30
+ },
31
+ async mutate(cluster, podInput, namespace, dryRun, ephemeral, uid, deps) {
32
+ if (!declarations.length || ephemeral)
33
+ return [];
34
+ const pod = podInput;
35
+ if (!pod.spec)
36
+ return [];
37
+ const existing = pod.metadata?.annotations?.[ANNOTATION];
38
+ if (existing) {
39
+ if (JSON.parse(existing).admission === uid)
40
+ return [];
41
+ throw new Error("remove inherited spectest.dev/coverage annotations before creating another Pod");
42
+ }
43
+ const patch = [];
44
+ const volumes = [...(pod.spec.volumes ?? [])];
45
+ const instances = [];
46
+ const namedVolume = (stem, volume) => {
47
+ let name = stem;
48
+ for (let i = 1; volumes.some(v => v.name === name); i++)
49
+ name = `${stem}-${i}`;
50
+ volumes.push({ name, ...volume });
51
+ return name;
52
+ };
53
+ for (const group of ["initContainers", "containers"]) {
54
+ for (const [index, container] of (pod.spec[group] ?? []).entries()) {
55
+ const requested = canonicalImageReference(container.image);
56
+ let declaration = declarations.find(image => image.reference === requested);
57
+ // Tangent deploys the prepared image by digest. Resolve aliases from
58
+ // CRI's actual image record, never by stripping a repository tag.
59
+ if (!declaration && requested.includes("@")) {
60
+ for (const candidate of declarations) {
61
+ let info = metadata.get(candidate.reference);
62
+ if (!info)
63
+ metadata.set(candidate.reference, info = await deps.image(candidate.reference));
64
+ if (info.aliases.includes(requested)) {
65
+ declaration = candidate;
66
+ break;
67
+ }
68
+ }
69
+ }
70
+ if (!declaration)
71
+ continue;
72
+ let info = metadata.get(declaration.reference);
73
+ if (!info)
74
+ metadata.set(declaration.reference, info = await deps.image(declaration.reference));
75
+ const id = hash(`${uid}/${group}/${container.name}`);
76
+ const name = service(cluster, declaration.reference);
77
+ const env = { ...info.env };
78
+ for (const entry of container.env ?? [])
79
+ if (entry.value !== undefined)
80
+ env[entry.name] = entry.value;
81
+ const configured = applyCoverageAdapters(name, { image: { type: "registry", reference: container.image }, env, coverage: declaration.coverage });
82
+ if (Object.keys(configured).some(key => !["image", "env", "coverage", "files"].includes(key))) {
83
+ throw new Error("Kubernetes coverage adapters may inject env and files only; configure the workload's command and mounts in its manifest");
84
+ }
85
+ const changed = Object.entries(configured.env ?? {}).filter(([key, value]) => env[key] !== value);
86
+ for (const [key] of changed)
87
+ if (container.env?.some(e => e.name === key && e.valueFrom))
88
+ throw new Error(`cannot inject coverage into dynamic ${key}`);
89
+ const mounts = [...(container.volumeMounts ?? [])];
90
+ const addMount = (mountPath, mount) => {
91
+ if (mounts.some(m => mountPath === m.mountPath || mountPath.startsWith(m.mountPath.replace(/\/$/, "") + "/")))
92
+ throw new Error(`coverage mount conflicts with ${mountPath}`);
93
+ mounts.push({ mountPath, ...mount });
94
+ };
95
+ const volume = namedVolume(`spectest-cov-${id}`, { hostPath: { path: `${MOUNT}/.pods/${id}`, type: "Directory" } });
96
+ addMount("/spectest/coverage", { name: volume });
97
+ const files = configured.files ?? [];
98
+ if (files.some(file => file.user !== undefined || file.group !== undefined || (file.mode !== undefined && !["444", "0444", "644", "0644"].includes(file.mode)))) {
99
+ throw new Error("Kubernetes coverage hook files must be world-readable and need no custom ownership");
100
+ }
101
+ if (files.length) {
102
+ const data = Object.fromEntries(files.map((file, index) => [`file${index}`, file.content]));
103
+ const configMap = `spectest-coverage-${hash(JSON.stringify(data))}`;
104
+ if (!dryRun)
105
+ await deps.files(namespace, configMap, data);
106
+ const fileVolume = namedVolume(`spectest-hooks-${id}`, { configMap: { name: configMap, defaultMode: 0o444 } });
107
+ files.forEach((file, index) => addMount(file.path, { name: fileVolume, subPath: `file${index}`, readOnly: true }));
108
+ }
109
+ const instance = { id, image: declaration.reference, namespace, container: container.name, root: container.workingDir || info.root || "/" };
110
+ if (!dryRun) {
111
+ const dir = path.join(coverageHostDir(WORKSPACE, cluster), ".pods", id);
112
+ await fs.mkdir(dir, { recursive: true, mode: 0o777 });
113
+ await fs.chmod(dir, 0o777);
114
+ await fs.writeFile(path.join(dir, ".instance.json"), JSON.stringify(instance));
115
+ }
116
+ instances.push(instance);
117
+ patch.push({ op: "add", path: `/spec/${group}/${index}/env`, value: [
118
+ ...(container.env ?? []).filter(e => !changed.some(([key]) => key === e.name)),
119
+ ...changed.map(([name, value]) => ({ name, value })),
120
+ ] }, { op: "add", path: `/spec/${group}/${index}/volumeMounts`, value: mounts });
121
+ }
122
+ }
123
+ if (!instances.length)
124
+ return [];
125
+ patch.push({ op: "add", path: "/spec/volumes", value: volumes });
126
+ patch.push({ op: "add", path: "/metadata/annotations", value: { ...pod.metadata?.annotations,
127
+ [ANNOTATION]: JSON.stringify({ admission: uid, instances: instances.map(i => i.id) }),
128
+ } });
129
+ return patch;
130
+ },
131
+ adapter() {
132
+ return {
133
+ name: "k3s-containers",
134
+ // Include nested policy AND injected hooks in the warm identity. An
135
+ // adapter's functions would otherwise disappear from JSON.stringify.
136
+ toJSON() {
137
+ 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
+ },
142
+ async collect(ctx, baseline) {
143
+ const root = path.join(ctx.reportDir, ".pods");
144
+ let entries;
145
+ try {
146
+ entries = await fs.readdir(root);
147
+ }
148
+ catch (error) {
149
+ if (error.code !== "ENOENT")
150
+ throw error;
151
+ entries = [];
152
+ }
153
+ // Native adapters flush through the shared Unix sockets, including
154
+ // after Pod deletion. Only inventory or command adapters need the API.
155
+ let pods;
156
+ const findPod = async (id) => {
157
+ pods ??= ctx.exec("kubectl get pods -A -o json --request-timeout=10s")
158
+ .then(result => JSON.parse(result.stdout).items);
159
+ return (await pods).find(p => {
160
+ const ids = p.metadata?.annotations?.[ANNOTATION];
161
+ return ids && JSON.parse(ids).instances.includes(id);
162
+ });
163
+ };
164
+ const captures = new Map();
165
+ const inventories = new Map();
166
+ for (const declaration of declarations)
167
+ captures.set(declaration.reference, { service: service(ctx.service, declaration.reference), reports: [] });
168
+ const collectInstance = async (id) => {
169
+ ctx.signal.throwIfAborted();
170
+ if (!/^[a-f0-9]{16}$/.test(id))
171
+ throw new Error("invalid coverage instance directory");
172
+ const dir = path.join(root, id);
173
+ const instance = JSON.parse(await fs.readFile(path.join(dir, ".instance.json"), "utf8"));
174
+ const declaration = declarations.find(d => d.reference === instance.image);
175
+ if (!declaration)
176
+ throw new Error(`coverage image no longer declared: ${instance.image}`);
177
+ const adapters = coverageAdapters(declaration.coverage);
178
+ const livePod = async () => {
179
+ const pod = await findPod(id);
180
+ const statuses = [...(pod?.status?.containerStatuses ?? []), ...(pod?.status?.initContainerStatuses ?? [])];
181
+ return statuses.some(status => status.name === instance.container && status.state?.running) ? pod : undefined;
182
+ };
183
+ for (const adapter of adapters) {
184
+ if (adapter.name === "node") {
185
+ try {
186
+ await fs.access(path.join(dir, `.attached-${adapter.name}`));
187
+ }
188
+ catch {
189
+ throw new Error(`coverage hook never attached to ${instance.namespace}/${instance.container} (${adapter.name})`);
190
+ }
191
+ }
192
+ }
193
+ const captured = await captureCoverageTarget({
194
+ coverage: declaration.coverage, root: instance.root, baseline,
195
+ inventoryAvailable: baseline && adapters.some(a => a.inventory) ? !!await livePod() : false,
196
+ counterKey: `${service(ctx.service, instance.image)}/${id}`,
197
+ context: { ...ctx, service: service(ctx.service, instance.image), reportDir: dir,
198
+ exec: async (command) => {
199
+ const pod = await livePod();
200
+ if (!pod?.metadata?.name)
201
+ throw new Error(`coverage command requires live container ${instance.container}`);
202
+ return ctx.exec(`kubectl exec -n ${quote(instance.namespace)} ${quote(pod.metadata.name)} -c ${quote(instance.container)} -- sh -c ${quote(command)}`);
203
+ },
204
+ },
205
+ });
206
+ return { id, instance, captured };
207
+ };
208
+ // Bound simultaneous LLVM exports and API execs. Drain all workers
209
+ // even on error so no collector can race the following VM snapshot.
210
+ const pending = entries.sort();
211
+ const results = new Map();
212
+ let cursor = 0;
213
+ const workers = await Promise.allSettled(Array.from({ length: Math.min(8, pending.length) }, async () => {
214
+ while (cursor < pending.length) {
215
+ const id = pending[cursor++];
216
+ results.set(id, await collectInstance(id));
217
+ }
218
+ }));
219
+ const failure = workers.find(worker => worker.status === "rejected");
220
+ if (failure?.status === "rejected")
221
+ throw failure.reason;
222
+ for (const id of pending) {
223
+ const { instance, captured } = results.get(id);
224
+ const aggregate = captures.get(instance.image);
225
+ if (captured.error)
226
+ aggregate.error = [aggregate.error, `${instance.namespace}/${instance.container}: ${captured.error}`].filter(Boolean).join("; ");
227
+ if (aggregate.root && aggregate.root !== instance.root) {
228
+ aggregate.error = "coverage containers from one image have different working directories; use a common workingDir for this image's covered containers";
229
+ }
230
+ aggregate.root = instance.root;
231
+ for (const report of captured.reports) {
232
+ if (report.name === "inventory.units") {
233
+ let units = inventories.get(instance.image);
234
+ if (!units)
235
+ inventories.set(instance.image, units = new Set());
236
+ for (const unit of report.content.split("\n"))
237
+ if (unit)
238
+ units.add(unit);
239
+ }
240
+ else
241
+ aggregate.reports.push({ ...report, name: `${id}/${report.name}` });
242
+ }
243
+ }
244
+ for (const [image, units] of inventories)
245
+ captures.get(image).reports.push({ name: "inventory.units", content: [...units].sort().join("\n") });
246
+ for (const capture of captures.values()) {
247
+ if (!capture.reports.length && !capture.error)
248
+ capture.reports.push({ name: "empty.lcov", content: "TN:\n" });
249
+ if (capture.reports.reduce((bytes, report) => bytes + Buffer.byteLength(report.content), 0) > COVERAGE_MAX_BYTES_PER_SERVICE) {
250
+ capture.reports = [];
251
+ capture.error = "Kubernetes image coverage exceeds the per-service report size limit";
252
+ }
253
+ }
254
+ return [...captures.values()];
255
+ },
256
+ };
257
+ },
258
+ };
259
+ }
@@ -12,7 +12,7 @@ interface Container {
12
12
  readOnly?: boolean;
13
13
  }[];
14
14
  }
15
- interface Pod {
15
+ export interface Pod {
16
16
  metadata?: {
17
17
  annotations?: Record<string, string>;
18
18
  };
@@ -32,7 +32,7 @@ interface Pod {
32
32
  ephemeralContainers?: Container[];
33
33
  };
34
34
  }
35
- interface Patch {
35
+ export interface Patch {
36
36
  op: "add";
37
37
  path: string;
38
38
  value: unknown;
@@ -43,6 +43,8 @@ export interface TrustDependencies {
43
43
  /** Ensure this immutable ConfigMap exists in the requesting namespace. */
44
44
  ensureBundle(namespace: string, name: string, bundle: string): Promise<void>;
45
45
  apply(configuration: unknown): Promise<void>;
46
+ /** Component-owned pod instrumentation runs after trust patching. */
47
+ mutatePod?(pod: Pod, namespace: string, dryRun: boolean, ephemeral: boolean, uid: string): Promise<Patch[]>;
46
48
  }
47
49
  export declare function installPodTrust(options: {
48
50
  name: string;
@@ -75,6 +75,19 @@ export async function installPodTrust(options, deps) {
75
75
  return;
76
76
  }
77
77
  const patch = podTrustPatch(request.object, configMap, request.subResource === "ephemeralcontainers");
78
+ if (deps.mutatePod) {
79
+ const pod = structuredClone(request.object);
80
+ for (const operation of patch) {
81
+ const keys = operation.path.slice(1).split("/");
82
+ const key = keys.pop();
83
+ const parent = keys.reduce((value, key) => value[key], pod);
84
+ if (key === "-")
85
+ parent.push(operation.value);
86
+ else
87
+ parent[key] = operation.value;
88
+ }
89
+ patch.push(...await deps.mutatePod(pod, request.namespace, !!request.dryRun, request.subResource === "ephemeralcontainers", request.uid));
90
+ }
78
91
  // Dry runs return the same patch but perform no API writes.
79
92
  if (patch.length && !request.dryRun)
80
93
  await deps.ensureBundle(request.namespace, configMap, options.bundle);
@@ -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";
@@ -1335,6 +1336,7 @@ export function k3s(opts = {}) {
1335
1336
  function createK3sCluster(opts, count) {
1336
1337
  const version = opts.version ?? DEFAULT_K3S_VERSION;
1337
1338
  const images = kubernetesImages(opts.images ?? []);
1339
+ const coverage = k3sCoverage(opts.images ?? []);
1338
1340
  const extra = opts.extraArgs ?? [];
1339
1341
  assertUsableExtraArgs(extra);
1340
1342
  const registryEnabled = opts.registry !== false;
@@ -1497,6 +1499,7 @@ function createK3sCluster(opts, count) {
1497
1499
  volumes: [{ name: `spectest-k3s-${cluster}-join`, target: JOIN_DIRECTORY }, ...(sticky
1498
1500
  ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
1499
1501
  : [{ target: CLUSTER_STORE_TARGET }]),
1502
+ ...(coverage.enabled ? [coverage.volume(cluster)] : []),
1500
1503
  ...(server && publishBuilds.length ? [
1501
1504
  { source: registryDirectory(SELF_SERVICE_TOKEN), target: REGISTRY_DATA },
1502
1505
  { source: contentDirectory, target: REGISTRY_CONTENT, readOnly: true },
@@ -1505,6 +1508,7 @@ function createK3sCluster(opts, count) {
1505
1508
  files: [
1506
1509
  ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
1507
1510
  ],
1511
+ ...(server && coverage.enabled ? { coverage: { adapters: [coverage.adapter()] } } : {}),
1508
1512
  // Agents start before the server and wait for its join token. The group
1509
1513
  // setup below gates readiness on every actual Kubernetes Node, including
1510
1514
  // the server, rather than treating container startup as cluster readiness.
@@ -1533,11 +1537,38 @@ function createK3sCluster(opts, count) {
1533
1537
  if (images.builds.length && !runtime)
1534
1538
  throw new Error(`k3s(${name}): prepared image inputs are missing`);
1535
1539
  const admission = (async () => {
1540
+ if (coverage.enabled && (!runtime?.caBundle || !caPresent()))
1541
+ throw new Error(`k3s(${name}): coverage injection requires the environment CA`);
1536
1542
  if (runtime?.caBundle && caPresent()) {
1537
1543
  const { cert, key } = await issueIngressCert([runtime.gateway]);
1538
1544
  await installPodTrust({ name, gateway: runtime.gateway, cert, key,
1539
1545
  ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
1540
1546
  }, {
1547
+ mutatePod: (pod, namespace, dryRun, ephemeral, uid) => coverage.mutate(name, pod, namespace, dryRun, ephemeral, uid, {
1548
+ async image(reference) {
1549
+ const result = await exec(name, ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock", "inspecti", reference]);
1550
+ if (result.exitCode !== 0)
1551
+ throw new Error(`coverage image metadata: ${result.stderr}`);
1552
+ const record = JSON.parse(result.stdout);
1553
+ const config = record.info?.imageSpec?.config;
1554
+ if (!config)
1555
+ throw new Error(`coverage image ${reference} has no OCI configuration`);
1556
+ return { aliases: record.status?.repoDigests ?? [], root: config.WorkingDir || "/",
1557
+ env: Object.fromEntries((config.Env ?? []).map((entry) => { const split = entry.indexOf("="); return [entry.slice(0, split), entry.slice(split + 1)]; })),
1558
+ };
1559
+ },
1560
+ async files(namespace, name, data) {
1561
+ try {
1562
+ await helpers.client.core.createNamespacedConfigMap({ namespace, body: { metadata: { name, namespace }, immutable: true, data } });
1563
+ }
1564
+ catch (error) {
1565
+ const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name })).unwrap();
1566
+ if (!existing.immutable || Object.keys(existing.data ?? {}).length !== Object.keys(data).length ||
1567
+ Object.entries(data).some(([key, value]) => existing.data?.[key] !== value))
1568
+ throw error;
1569
+ }
1570
+ },
1571
+ }),
1541
1572
  apply: async (configuration) => { await helpers.apply(JSON.stringify(configuration)); },
1542
1573
  ensureBundle: async (namespace, configMap, bundle) => {
1543
1574
  try {