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