@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.
- package/dist/android-step.d.ts +7 -0
- package/dist/android.d.ts +17 -3
- package/dist/android.js +122 -26
- package/dist/components/android.d.ts +1 -1
- package/dist/components/android.js +1 -1
- package/dist/components/k3s-coverage.d.ts +24 -0
- package/dist/components/k3s-coverage.js +259 -0
- package/dist/components/k3s-trust.d.ts +4 -2
- package/dist/components/k3s-trust.js +13 -0
- package/dist/components/k3s.js +31 -0
- package/dist/coverage/rust.rs +102 -0
- package/dist/coverage-bun.d.ts +10 -0
- package/dist/coverage-bun.js +32 -0
- package/dist/coverage-rust.d.ts +23 -0
- package/dist/coverage-rust.js +71 -0
- package/dist/coverage.d.ts +11 -5
- package/dist/coverage.js +70 -13
- package/dist/daemon.js +27 -2
- package/dist/harness/component-images.d.ts +1 -0
- package/dist/harness/component-images.js +4 -2
- package/dist/harness/coverage-target.d.ts +10 -0
- package/dist/harness/coverage-target.js +54 -0
- package/dist/index.d.ts +21 -1
- package/dist/index.js +34 -1
- package/package.json +1 -1
- package/src/android-actions.test.ts +121 -0
- package/src/android-step.ts +2 -0
- package/src/android.test.ts +3 -0
- package/src/android.ts +107 -33
- package/src/components/android.test.ts +1 -0
- package/src/components/android.ts +2 -2
- package/src/components/k3s-coverage.test.ts +160 -0
- package/src/components/k3s-coverage.ts +234 -0
- package/src/components/k3s-trust.ts +14 -2
- package/src/components/k3s.ts +26 -0
- package/src/coverage/rust.rs +102 -0
- package/src/coverage-bun.test.ts +103 -0
- package/src/coverage-bun.ts +40 -0
- package/src/coverage-rust.test.ts +74 -0
- package/src/coverage-rust.ts +79 -0
- package/src/coverage.test.ts +4 -4
- package/src/coverage.ts +74 -13
- package/src/daemon.ts +22 -2
- package/src/harness/component-images.ts +5 -2
- package/src/harness/coverage-target.ts +55 -0
- package/src/index.ts +44 -4
package/dist/android-step.d.ts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
241
|
+
const visiblePath = async () => {
|
|
199
242
|
try {
|
|
200
|
-
|
|
243
|
+
const path = await find();
|
|
244
|
+
return await command("GET", `${path}/displayed`) ? path : undefined;
|
|
201
245
|
}
|
|
202
246
|
catch (error) {
|
|
203
|
-
if (error
|
|
204
|
-
|
|
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
|
|
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
|
|
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 () =>
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
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:
|
|
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);
|