@specific.dev/spectest 0.126.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.
Files changed (46) hide show
  1. package/dist/android-step.d.ts +7 -0
  2. package/dist/android.d.ts +17 -3
  3. package/dist/android.js +122 -26
  4. package/dist/components/android.d.ts +1 -1
  5. package/dist/components/android.js +1 -1
  6. package/dist/components/k3s-coverage.d.ts +24 -0
  7. package/dist/components/k3s-coverage.js +259 -0
  8. package/dist/components/k3s-trust.d.ts +4 -2
  9. package/dist/components/k3s-trust.js +13 -0
  10. package/dist/components/k3s.js +31 -0
  11. package/dist/coverage/rust.rs +102 -0
  12. package/dist/coverage-bun.d.ts +10 -0
  13. package/dist/coverage-bun.js +32 -0
  14. package/dist/coverage-rust.d.ts +23 -0
  15. package/dist/coverage-rust.js +71 -0
  16. package/dist/coverage.d.ts +11 -5
  17. package/dist/coverage.js +70 -13
  18. package/dist/daemon.js +27 -2
  19. package/dist/harness/component-images.d.ts +1 -0
  20. package/dist/harness/component-images.js +4 -2
  21. package/dist/harness/coverage-target.d.ts +10 -0
  22. package/dist/harness/coverage-target.js +54 -0
  23. package/dist/index.d.ts +21 -1
  24. package/dist/index.js +34 -1
  25. package/package.json +1 -1
  26. package/src/android-actions.test.ts +121 -0
  27. package/src/android-step.ts +2 -0
  28. package/src/android.test.ts +3 -0
  29. package/src/android.ts +107 -33
  30. package/src/components/android.test.ts +1 -0
  31. package/src/components/android.ts +2 -2
  32. package/src/components/k3s-coverage.test.ts +160 -0
  33. package/src/components/k3s-coverage.ts +234 -0
  34. package/src/components/k3s-trust.ts +14 -2
  35. package/src/components/k3s.ts +26 -0
  36. package/src/coverage/rust.rs +102 -0
  37. package/src/coverage-bun.test.ts +103 -0
  38. package/src/coverage-bun.ts +40 -0
  39. package/src/coverage-rust.test.ts +74 -0
  40. package/src/coverage-rust.ts +79 -0
  41. package/src/coverage.test.ts +4 -4
  42. package/src/coverage.ts +74 -13
  43. package/src/daemon.ts +22 -2
  44. package/src/harness/component-images.ts +5 -2
  45. package/src/harness/coverage-target.ts +55 -0
  46. package/src/index.ts +44 -4
@@ -8,6 +8,13 @@ export interface AndroidTarget {
8
8
  export interface AndroidStepDetails {
9
9
  device: string;
10
10
  action: "tap" | "fill" | "text" | "visible" | "wait" | "swipe" | "back" | "source" | "screenshot" | "execute";
11
+ /** Normalized screen bounds observed by this operation. */
12
+ bounds?: {
13
+ x: number;
14
+ y: number;
15
+ width: number;
16
+ height: number;
17
+ };
11
18
  target?: AndroidTarget;
12
19
  point?: {
13
20
  x: number;
package/dist/android.d.ts CHANGED
@@ -24,10 +24,24 @@ export declare class AppiumError extends Error {
24
24
  readonly code: string;
25
25
  constructor(code: string, message: string);
26
26
  }
27
+ export declare const ANDROID_ASSERT: unique symbol;
28
+ export type AndroidMatcher = "toBeVisible" | "toBeHidden" | "toHaveText" | "toContainText";
29
+ export interface AndroidAssertionResult {
30
+ sourceSeq?: number;
31
+ actual?: unknown;
32
+ error?: unknown;
33
+ }
27
34
  export interface AndroidElement {
28
- tap(): Promise<void>;
29
- fill(text: string): Promise<void>;
30
- text(): Promise<Wrapped<string>>;
35
+ [ANDROID_ASSERT](matcher: AndroidMatcher, negated: boolean, expected: string | RegExp | undefined, timeoutMs?: number): Promise<AndroidAssertionResult>;
36
+ tap(options?: {
37
+ timeoutMs?: number;
38
+ }): Promise<void>;
39
+ fill(text: string, options?: {
40
+ timeoutMs?: number;
41
+ }): Promise<void>;
42
+ text(options?: {
43
+ timeoutMs?: number;
44
+ }): Promise<Wrapped<string>>;
31
45
  isDisplayed(): Promise<Wrapped<boolean>>;
32
46
  waitFor(options?: {
33
47
  state?: "visible" | "hidden";
package/dist/android.js CHANGED
@@ -42,6 +42,10 @@ async function session(app) {
42
42
  capabilities: {
43
43
  alwaysMatch: {
44
44
  platformName: "Android",
45
+ // Conditions are explicit waits. Do not stack a session-wide lookup
46
+ // wait or UiSelector's own 10s wait underneath their deadline.
47
+ timeouts: { implicit: 0 },
48
+ "appium:settings[waitForSelectorTimeout]": 0,
45
49
  "appium:automationName": "UiAutomator2",
46
50
  "appium:udid": app.serial,
47
51
  "appium:appPackage": app.package,
@@ -65,6 +69,7 @@ async function session(app) {
65
69
  }
66
70
  return pending;
67
71
  }
72
+ export const ANDROID_ASSERT = Symbol.for("spectest.androidAssert");
68
73
  const parked = new Map();
69
74
  let epoch = 0;
70
75
  /** A case owns capture and handle lifetime; the Android/Appium processes outlive it. */
@@ -155,13 +160,13 @@ export function androidScope(caseId, testStartedAt, options = {}) {
155
160
  list.push(marker);
156
161
  taps.set(app.name, list);
157
162
  }
158
- async function op(operation, fn) {
163
+ async function op(operation, fn, settled) {
159
164
  assertActive();
160
165
  const android = { device: app.name, ...operation };
161
166
  const title = androidStepTitle(android);
162
167
  const reservation = reserveEvent(), started = Date.now();
163
168
  try {
164
- const result = await fn();
169
+ const result = await fn(android);
165
170
  const seq = recordStep({
166
171
  kind: "android",
167
172
  title,
@@ -169,10 +174,11 @@ export function androidScope(caseId, testStartedAt, options = {}) {
169
174
  durationMs: Date.now() - started,
170
175
  status: "passed",
171
176
  }, reservation);
177
+ settled?.(seq);
172
178
  return wrap(result, seq);
173
179
  }
174
180
  catch (error) {
175
- recordStep({
181
+ const seq = recordStep({
176
182
  kind: "android",
177
183
  title,
178
184
  android,
@@ -180,9 +186,46 @@ export function androidScope(caseId, testStartedAt, options = {}) {
180
186
  status: "failed",
181
187
  error: String(error),
182
188
  }, reservation);
189
+ settled?.(seq);
183
190
  throw error;
184
191
  }
185
192
  }
193
+ async function bounds(path, details) {
194
+ delete details.bounds;
195
+ if (!captures.has(app.name))
196
+ return;
197
+ try {
198
+ if (!await command("GET", `${path}/displayed`))
199
+ return;
200
+ const screen = await command("GET", "/window/rect");
201
+ const rect = await command("GET", `${path}/rect`);
202
+ const box = { x: rect.x / screen.width, y: rect.y / screen.height, width: rect.width / screen.width, height: rect.height / screen.height };
203
+ if (Object.values(box).every(Number.isFinite) && box.width > 0 && box.height > 0)
204
+ details.bounds = box;
205
+ }
206
+ catch { /* Replay decoration must not change the operation's result. */ }
207
+ }
208
+ const transient = (error) => error instanceof AppiumError && ["no such element", "stale element reference"].includes(error.code);
209
+ async function until(probe, label, timeoutMs = 10000) {
210
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
211
+ throw new Error("Invalid wait timeout");
212
+ const deadline = Date.now() + timeoutMs;
213
+ while (true) {
214
+ assertActive();
215
+ try {
216
+ const value = await probe();
217
+ if (value !== undefined)
218
+ return value;
219
+ }
220
+ catch (error) {
221
+ if (!transient(error))
222
+ throw error;
223
+ }
224
+ if (Date.now() >= deadline)
225
+ throw new Error(`Timed out waiting for ${label}`);
226
+ await new Promise(resolve => setTimeout(resolve, Math.min(100, deadline - Date.now())));
227
+ }
228
+ }
186
229
  const element = (using, value, target = androidTarget(using, value)) => {
187
230
  const label = androidTargetLabel(target);
188
231
  const find = async () => {
@@ -195,49 +238,102 @@ export function androidScope(caseId, testStartedAt, options = {}) {
195
238
  throw new Error("Appium returned no element id");
196
239
  return `/element/${encodeURIComponent(id)}`;
197
240
  };
198
- const displayed = async () => {
241
+ const visiblePath = async () => {
199
242
  try {
200
- return await command("GET", `${await find()}/displayed`);
243
+ const path = await find();
244
+ return await command("GET", `${path}/displayed`) ? path : undefined;
201
245
  }
202
246
  catch (error) {
203
- if (error instanceof AppiumError &&
204
- ["no such element", "stale element reference"].includes(error.code))
205
- return false;
247
+ if (transient(error))
248
+ return undefined;
206
249
  throw error;
207
250
  }
208
251
  };
252
+ const ready = (timeoutMs) => until(async () => {
253
+ const path = await visiblePath();
254
+ return path && await command("GET", `${path}/enabled`) ? path : undefined;
255
+ }, `${label} to be visible and enabled`, timeoutMs);
209
256
  return {
210
- async tap() {
257
+ async tap(options = {}) {
211
258
  await op({ action: "tap", target }, async () => {
212
- const path = await find();
259
+ const path = await ready(options.timeoutMs);
213
260
  const marker = await tapMarker(path);
261
+ // Never retry an input dispatch: a transport failure may occur
262
+ // after the app has received it.
214
263
  await command("POST", `${path}/click`, {});
215
264
  saveTap(marker);
216
265
  });
217
266
  },
218
- async fill(text) {
219
- await op({ action: "fill", target }, async () => {
220
- const path = await find();
267
+ async fill(text, options = {}) {
268
+ await op({ action: "fill", target }, async (details) => {
269
+ const path = await ready(options.timeoutMs);
221
270
  await command("POST", `${path}/clear`, {});
222
271
  await command("POST", `${path}/value`, { text });
272
+ await bounds(path, details);
223
273
  });
224
274
  },
225
- text: () => op({ action: "text", target }, async () => command("GET", `${await find()}/text`)),
226
- isDisplayed: () => op({ action: "visible", target }, displayed),
275
+ text: (options = {}) => op({ action: "text", target }, async (details) => {
276
+ return until(async () => {
277
+ const path = await find();
278
+ const value = await command("GET", `${path}/text`);
279
+ await bounds(path, details);
280
+ return value;
281
+ }, label, options.timeoutMs);
282
+ }),
283
+ isDisplayed: () => op({ action: "visible", target }, async (details) => {
284
+ const path = await visiblePath();
285
+ if (path)
286
+ await bounds(path, details);
287
+ return !!path;
288
+ }),
227
289
  async waitFor(options = {}) {
228
- const timeout = options.timeoutMs ?? 10000;
229
- if (!Number.isFinite(timeout) || timeout < 0)
230
- throw new Error("Invalid wait timeout");
231
- await op({ action: "wait", target, state: options.state ?? "visible" }, async () => {
232
- const end = Date.now() + timeout;
233
- do {
234
- if ((await displayed()) === (options.state !== "hidden"))
235
- return;
236
- await new Promise((resolve) => setTimeout(resolve, 100));
237
- } while (Date.now() < end);
238
- throw new Error(`Timed out waiting for ${label}`);
290
+ await op({ action: "wait", target, state: options.state ?? "visible" }, async (details) => {
291
+ await until(async () => {
292
+ const path = await visiblePath();
293
+ if (path)
294
+ await bounds(path, details);
295
+ else
296
+ delete details.bounds;
297
+ return (!!path === (options.state !== "hidden")) ? true : undefined;
298
+ }, `${label} to be ${options.state ?? "visible"}`, options.timeoutMs);
239
299
  });
240
300
  },
301
+ async [ANDROID_ASSERT](matcher, negated, expected, timeoutMs) {
302
+ let sourceSeq, actual, error;
303
+ const visibility = matcher === "toBeVisible" || matcher === "toBeHidden";
304
+ try {
305
+ await op({ action: visibility ? "wait" : "text", target,
306
+ ...(visibility ? { state: ((matcher === "toBeVisible") !== negated ? "visible" : "hidden") } : {}) }, async (details) => {
307
+ await until(async () => {
308
+ let path;
309
+ let matches;
310
+ if (visibility) {
311
+ path = await visiblePath();
312
+ actual = !!path;
313
+ matches = matcher === "toBeVisible" ? !!path : !path;
314
+ }
315
+ else {
316
+ path = await find();
317
+ const text = await command("GET", `${path}/text`);
318
+ actual = text;
319
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
320
+ matches = expected instanceof RegExp
321
+ ? new RegExp(expected.source, expected.flags).test(text)
322
+ : matcher === "toContainText" ? normalize(text).includes(normalize(expected)) : normalize(text) === normalize(expected);
323
+ }
324
+ if (path)
325
+ await bounds(path, details);
326
+ else
327
+ delete details.bounds;
328
+ return matches !== negated ? true : undefined;
329
+ }, `${label} ${negated ? "not " : ""}${matcher}${expected === undefined ? "" : ` ${expected}`}`, timeoutMs);
330
+ }, seq => { sourceSeq = seq; });
331
+ }
332
+ catch (cause) {
333
+ error = cause;
334
+ }
335
+ return { sourceSeq, actual, error };
336
+ },
241
337
  };
242
338
  };
243
339
  return {
@@ -6,7 +6,7 @@ export interface AndroidOptions {
6
6
  package: string;
7
7
  /** Launch activity, for example .MainActivity. */
8
8
  activity: string;
9
- /** Android image; default is the tested Android 14 arm64-only redroid image. */
9
+ /** Android image; default is the pinned Android 16 64-bit-only redroid image. */
10
10
  image?: string;
11
11
  /** Optional prebuilt image containing the same tools as ANDROID_TOOLS_DOCKERFILE. */
12
12
  toolsImage?: string;
@@ -47,7 +47,7 @@ export function android(options) {
47
47
  image: {
48
48
  type: "registry",
49
49
  reference: options.image ??
50
- "redroid/redroid:14.0.0_64only-latest@sha256:0a611199ba2e0b5d60af39b3327a517f6407231f4352114ed3bd3cbfe2be69aa",
50
+ "redroid/redroid:16.0.0_64only-latest@sha256:7b1e389bd15f37af3bcd06138f5b2ffa7cfba4332bd5ef54c53e99c2f160a15b",
51
51
  },
52
52
  privileged: true,
53
53
  // Android's netd ignores resolv.conf. Use Docker's per-container DNS,
@@ -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);