@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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { rust } from "./coverage-rust";
|
|
8
|
+
import { captureCoverageTarget } from "./harness/coverage-target";
|
|
9
|
+
import { encodeCoverageBundle } from "./harness/coverage";
|
|
10
|
+
|
|
11
|
+
async function run(args: string[], cwd?: string) {
|
|
12
|
+
const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
13
|
+
const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
|
14
|
+
if (code) throw new Error(`${args[0]}: ${stderr}`);
|
|
15
|
+
return stdout.trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const installedTools = (() => {
|
|
19
|
+
try {
|
|
20
|
+
const sysroot = execFileSync("rustc", ["--print", "sysroot"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
21
|
+
const host = execFileSync("rustc", ["-vV"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).match(/^host: (.+)$/m)![1];
|
|
22
|
+
const tools = path.join(sysroot, "lib/rustlib", host, "bin");
|
|
23
|
+
return existsSync(path.join(tools, "llvm-cov")) && existsSync(path.join(tools, "llvm-profdata")) ? tools : undefined;
|
|
24
|
+
} catch { return undefined; }
|
|
25
|
+
})();
|
|
26
|
+
test.skipIf(!installedTools)("Rust real LLVM reports capture live and exited processes, subtract baseline, and fail on lost processes", async () => {
|
|
27
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rust-cov-"));
|
|
28
|
+
let child: ReturnType<typeof Bun.spawn> | undefined;
|
|
29
|
+
try {
|
|
30
|
+
const tools = installedTools!;
|
|
31
|
+
await fs.access(path.join(tools, "llvm-cov"));
|
|
32
|
+
await fs.mkdir(path.join(root, "reports"));
|
|
33
|
+
await fs.copyFile(new URL("./coverage/rust.rs", import.meta.url), path.join(root, "spectest-coverage.rs"));
|
|
34
|
+
await fs.writeFile(path.join(root, "main.rs"), `#[path="spectest-coverage.rs"] mod coverage;\nuse std::io::{self, BufRead, Write};\nfn choose(left: bool) -> u32 {\n if left { 41 } else { 42 }\n}\nfn main() {\n coverage::start().unwrap();\n println!("ready");\n io::stdout().flush().unwrap();\n for line in io::stdin().lock().lines() {\n println!("{}",choose(line.unwrap()=="left"));\n io::stdout().flush().unwrap();\n }\n}\n`);
|
|
35
|
+
await run(["rustc", "--edition=2024", "-C", "instrument-coverage", "-C", "opt-level=0", "main.rs", "-o", "server"], root);
|
|
36
|
+
const env = { ...process.env, SPECTEST_COVERAGE_DIR: `${root}/reports`, SPECTEST_LLVM_COV: `${tools}/llvm-cov`, SPECTEST_LLVM_PROFDATA: `${tools}/llvm-profdata` };
|
|
37
|
+
const launch = () => Bun.spawn([`${root}/server`], { cwd: root, env, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
38
|
+
child = launch();
|
|
39
|
+
let reader = (child.stdout as ReadableStream<Uint8Array>).getReader();
|
|
40
|
+
const ready = new TextDecoder().decode((await reader.read()).value);
|
|
41
|
+
if (!ready.includes("ready")) throw new Error(await new Response(child.stderr).text());
|
|
42
|
+
const context = { service: "rust", reportDir: `${root}/reports`, signal: AbortSignal.timeout(10000),
|
|
43
|
+
exec: async () => ({ stdout: "", stderr: "" }), writeReport: async () => {}, projectFiles: async () => [], readProjectFile: async () => null };
|
|
44
|
+
const capture = (baseline = false) => captureCoverageTarget({ context, coverage: { adapters: [rust()] }, root, baseline });
|
|
45
|
+
const setup = await capture(true);
|
|
46
|
+
expect(setup.error).toBeUndefined();
|
|
47
|
+
(child.stdin as { write(value: string): void }).write("left\n");
|
|
48
|
+
expect(new TextDecoder().decode((await reader.read()).value)).toContain("41");
|
|
49
|
+
const changed = await capture();
|
|
50
|
+
if (process.env.SPECTEST_RUNTIME_COVERAGE_EVIDENCE) {
|
|
51
|
+
const output = path.join(process.env.SPECTEST_RUNTIME_COVERAGE_EVIDENCE, "rust");
|
|
52
|
+
await fs.mkdir(output, { recursive: true });
|
|
53
|
+
await fs.writeFile(path.join(output, "setup.json.gz"), encodeCoverageBundle("__setup__", [setup]));
|
|
54
|
+
await fs.writeFile(path.join(output, "left.json.gz"), encodeCoverageBundle("left", [changed]));
|
|
55
|
+
await fs.writeFile(path.join(output, "tree.txt"), "main.rs\n");
|
|
56
|
+
await fs.writeFile(path.join(output, "lines.json"), JSON.stringify({ "main.rs": 14 }));
|
|
57
|
+
}
|
|
58
|
+
expect(changed.error).toBeUndefined();
|
|
59
|
+
expect(changed.reports.map(r => r.content).join()).toMatch(/DA:4,[1-9]/);
|
|
60
|
+
const idle = await capture();
|
|
61
|
+
expect(idle.error).toBeUndefined();
|
|
62
|
+
expect(idle.reports.map(r => r.content).join()).toBe("TN:\n");
|
|
63
|
+
(child.stdin as { end(): void }).end();
|
|
64
|
+
expect(await child.exited).toBe(0);
|
|
65
|
+
expect((await capture()).error).toBeUndefined();
|
|
66
|
+
// A new process has a unique profile, even if the old PID is reused.
|
|
67
|
+
child = launch();
|
|
68
|
+
reader = (child.stdout as ReadableStream<Uint8Array>).getReader();
|
|
69
|
+
expect(new TextDecoder().decode((await reader.read()).value)).toContain("ready");
|
|
70
|
+
child.kill("SIGKILL");
|
|
71
|
+
await child.exited;
|
|
72
|
+
expect((await capture()).error).toContain("without a final report");
|
|
73
|
+
} finally { child?.kill(); await fs.rm(root, { recursive: true, force: true }); }
|
|
74
|
+
}, 30000);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { promises as fs, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { connect } from "node:net";
|
|
4
|
+
import type { CoverageAdapter, CoverageSourceOptions } from "./coverage.js";
|
|
5
|
+
|
|
6
|
+
export interface RustCoverageOptions extends CoverageSourceOptions {
|
|
7
|
+
/** Only for idle command containers where no Rust process has run yet. */
|
|
8
|
+
allowEmpty?: boolean;
|
|
9
|
+
/** Compiler workspace directory, when different from the runtime workdir.
|
|
10
|
+
* Exact SF paths below it are made repository-relative before archiving. */
|
|
11
|
+
buildRoot?: string;
|
|
12
|
+
/** Matching rustup llvm-tools-preview executables, in the runtime image. */
|
|
13
|
+
llvmCov?: string;
|
|
14
|
+
llvmProfdata?: string;
|
|
15
|
+
/** LLVM filename regex for external/generated sources, not application code. */
|
|
16
|
+
ignoreFilenameRegex?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Source for a test-build-only module, copied as spectest-coverage.rs. The
|
|
20
|
+
* application links only this std-only helper, never the JavaScript SDK. */
|
|
21
|
+
export function rustHelperSource(): string {
|
|
22
|
+
return readFileSync(new URL("./coverage/rust.rs", import.meta.url), "utf8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Flush a socket-backed collector. A dead process without a final flush is
|
|
26
|
+
* incomplete coverage, never an empty successful report. */
|
|
27
|
+
export async function flushCoverageSockets(dir: string, prefix: string, signal: AbortSignal): Promise<number> {
|
|
28
|
+
const names = (await fs.readdir(dir)).filter(name => name.startsWith(prefix));
|
|
29
|
+
for (const name of names) {
|
|
30
|
+
signal.throwIfAborted();
|
|
31
|
+
await new Promise<void>((resolve, reject) => {
|
|
32
|
+
const socket = connect(path.join(dir, name), () => socket.end("dump\n"));
|
|
33
|
+
let reply = "";
|
|
34
|
+
const abort = () => socket.destroy(new Error(`coverage flush aborted: ${name}`));
|
|
35
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
36
|
+
socket.setTimeout(55_000, () => socket.destroy(new Error(`coverage flush timed out: ${name}`)));
|
|
37
|
+
socket.on("data", chunk => { reply += chunk; if (reply.length > 8192) socket.destroy(new Error("oversized coverage reply")); });
|
|
38
|
+
socket.on("error", error => reject(new Error(`coverage process ${name} exited without a final report or could not be reached: ${error.message}`)));
|
|
39
|
+
socket.on("close", () => {
|
|
40
|
+
signal.removeEventListener("abort", abort);
|
|
41
|
+
if (reply.trim() === "ok") resolve();
|
|
42
|
+
else reject(new Error(`coverage process ${name} did not flush: ${reply.trim() || "process exited without a final report"}`));
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return names.length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Requires instrumented binaries calling the shipped coverage/rust.rs helper.
|
|
50
|
+
* The helper exports cumulative per-process LCOV before exit and on request;
|
|
51
|
+
* no compiler tools or binaries need to be copied out of terminated pods. */
|
|
52
|
+
export function rust(options: RustCoverageOptions = {}): CoverageAdapter {
|
|
53
|
+
const { sourceRoot, excludeSources, llvmCov, llvmProfdata, ignoreFilenameRegex, buildRoot } = options;
|
|
54
|
+
if (buildRoot !== undefined && (!buildRoot.startsWith("/") || buildRoot === "/" || /[\0\r\n]/.test(buildRoot) || buildRoot.split("/").includes(".."))) throw new Error("Rust buildRoot must be an absolute compiler workspace directory");
|
|
55
|
+
for (const value of [llvmCov, llvmProfdata, ignoreFilenameRegex]) {
|
|
56
|
+
if (value !== undefined && (!value || /[\0\r\n]/.test(value))) throw new Error("invalid Rust coverage option");
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
name: "rust", reports: "cumulative",
|
|
60
|
+
sourceOptions: { ...(sourceRoot !== undefined ? { sourceRoot } : {}), ...(excludeSources ? { excludeSources } : {}) },
|
|
61
|
+
ownsReport: name => /^rust-.*\.lcov$/.test(name),
|
|
62
|
+
configure: service => ({ ...service, env: { ...service.env,
|
|
63
|
+
SPECTEST_COVERAGE_DIR: "/spectest/coverage",
|
|
64
|
+
...(buildRoot ? { SPECTEST_RUST_BUILD_ROOT: buildRoot } : {}),
|
|
65
|
+
...(llvmCov ? { SPECTEST_LLVM_COV: llvmCov } : {}),
|
|
66
|
+
...(llvmProfdata ? { SPECTEST_LLVM_PROFDATA: llvmProfdata } : {}),
|
|
67
|
+
...(ignoreFilenameRegex ? { SPECTEST_RUST_IGNORE: ignoreFilenameRegex } : {}),
|
|
68
|
+
} }),
|
|
69
|
+
async capture(ctx) {
|
|
70
|
+
await flushCoverageSockets(ctx.reportDir, ".rust-ctl-", ctx.signal);
|
|
71
|
+
const names = await fs.readdir(ctx.reportDir);
|
|
72
|
+
if (names.some(name => name.startsWith("rust-error-"))) throw new Error("Rust coverage helper failed; inspect rust-error report");
|
|
73
|
+
if (!names.some(name => /^rust-.*\.lcov$/.test(name))) {
|
|
74
|
+
if (options.allowEmpty) { await ctx.writeReport("rust-empty.lcov", "TN:\n"); return; }
|
|
75
|
+
throw new Error("no Rust coverage reports: build with -C instrument-coverage and call spectest_coverage::start() before application work");
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/coverage.test.ts
CHANGED
|
@@ -540,7 +540,7 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
540
540
|
const names = await dumpsIn(dir);
|
|
541
541
|
expect(names.length).toBe(2);
|
|
542
542
|
const byThread = new Map<string, Dump>();
|
|
543
|
-
for (const n of names) byThread.set(n.split("-")[
|
|
543
|
+
for (const n of names) byThread.set(n.split("-")[3]!, await readDump(dir, n));
|
|
544
544
|
const recs = await records(dir);
|
|
545
545
|
expect(recs.size).toBe(1); // one script, one record, whichever thread came first
|
|
546
546
|
const rec = recs.get(`file://${path.join(dir, "threads.js")}`)!;
|
|
@@ -600,14 +600,14 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
600
600
|
expect(out.trim().split("\n")).toEqual(["npm:@s/b@2.0.0", "npm:a@1.0.0", "npm:c@3.0.0", "npm:d@4.0.0", "npm:e", "npm:f@6.0.0"]);
|
|
601
601
|
});
|
|
602
602
|
|
|
603
|
-
test("a
|
|
603
|
+
test("a killed covered process makes capture incomplete instead of silently losing execution", async () => {
|
|
604
604
|
const dir = await scratch();
|
|
605
605
|
const server = await start(dir, "server.js", 1);
|
|
606
606
|
server.kill("SIGKILL");
|
|
607
607
|
await new Promise<void>((r) => server.on("exit", () => r()));
|
|
608
608
|
expect((await socketsIn(dir)).length).toBe(1); // SIGKILL: no exit handler, socket left behind
|
|
609
|
-
await node().capture!(fakeCtx(dir));
|
|
610
|
-
expect((await socketsIn(dir)).length).toBe(
|
|
609
|
+
await expect(node().capture!(fakeCtx(dir))).rejects.toThrow("without a final report");
|
|
610
|
+
expect((await socketsIn(dir)).length).toBe(1);
|
|
611
611
|
});
|
|
612
612
|
|
|
613
613
|
test("no process and no dump: an empty report is written, once", async () => {
|
package/src/coverage.ts
CHANGED
|
@@ -36,6 +36,8 @@ import { harvestAllBrowserCoverage, takeBrowserCoverageReport } from "./browser-
|
|
|
36
36
|
import { COVERAGE_CONTAINER_DIR, type CoverageReportsMode } from "./harness/coverage.js";
|
|
37
37
|
export { COVERAGE_CONTAINER_DIR, type CoverageReportsMode };
|
|
38
38
|
export { next, type NextCoverageOptions } from "./coverage-next.js";
|
|
39
|
+
export { bun, type BunCoverageOptions } from "./coverage-bun.js";
|
|
40
|
+
export { rust, rustHelperSource, type RustCoverageOptions } from "./coverage-rust.js";
|
|
39
41
|
|
|
40
42
|
/** Where `node()` mounts its hook inside the container. */
|
|
41
43
|
export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
@@ -44,7 +46,7 @@ export const NODE_COVERAGE_HOOK_PATH = "/spectest/coverage-hook.cjs";
|
|
|
44
46
|
export const NODE_COVERAGE_EMPTY_REPORT = "coverage-spectest-empty.json";
|
|
45
47
|
|
|
46
48
|
/** Prefix of the per-process sockets the node hook answers on, relative
|
|
47
|
-
* to the coverage dir: `.ctl-<pid>`. */
|
|
49
|
+
* to the coverage dir: `.ctl-<pid>-<nonce>`. */
|
|
48
50
|
export const NODE_COVERAGE_SOCKET_PREFIX = ".ctl-";
|
|
49
51
|
|
|
50
52
|
|
|
@@ -150,6 +152,10 @@ export interface CoverageAdapter {
|
|
|
150
152
|
load?(ctx: CoverageLoadContext): void | Promise<void>;
|
|
151
153
|
/** Make the report appear in the directory. A throw fails the test. */
|
|
152
154
|
capture?(ctx: CoverageCaptureContext): void | Promise<void>;
|
|
155
|
+
/** Component-owned containers (for example k3s pods). Their reports retain
|
|
156
|
+
* their own source roots and counter baselines, rather than the outer
|
|
157
|
+
* service's filesystem identity. Internal: ordinary adapters use capture. */
|
|
158
|
+
collect?(ctx: CoverageInventoryContext & CoverageCaptureContext, baseline: boolean): Promise<import("./harness/coverage.js").ServiceCoverageCapture[]>;
|
|
153
159
|
/**
|
|
154
160
|
* Project paths (gitignore-style patterns, project-relative) whose
|
|
155
161
|
* changes this adapter's **inventory** accounts for — a package
|
|
@@ -245,7 +251,7 @@ export function validateCoverage(service: string, cov: unknown): void {
|
|
|
245
251
|
((a as CoverageAdapter).explains === undefined ||
|
|
246
252
|
(Array.isArray((a as CoverageAdapter).explains) &&
|
|
247
253
|
((a as CoverageAdapter).explains as unknown[]).every((e) => typeof e === "string"))) &&
|
|
248
|
-
["configure", "load", "capture", "inventory", "ownsReport"].every(
|
|
254
|
+
["configure", "load", "capture", "collect", "inventory", "ownsReport"].every(
|
|
249
255
|
(k) =>
|
|
250
256
|
(a as Record<string, unknown>)[k] === undefined ||
|
|
251
257
|
typeof (a as Record<string, unknown>)[k] === "function",
|
|
@@ -398,7 +404,7 @@ export const NODE_COVERAGE_DUMP_PREFIX = "node-";
|
|
|
398
404
|
* without `--enable-source-maps`. For a bundled CLI that was 12 MB per
|
|
399
405
|
* invocation, inside the test's own time, parsed again at capture.
|
|
400
406
|
* - **Takes** on request — the main thread binds a Unix socket
|
|
401
|
-
* `.ctl-<pid>` in the coverage directory and takes on `dump\n`; every
|
|
407
|
+
* `.ctl-<pid>-<nonce>` in the coverage directory and takes on `dump\n`; every
|
|
402
408
|
* process binds its own (the first node process is often a wrapper,
|
|
403
409
|
* `pnpm exec`, `tsx`, `npm run`) — and in every thread's `exit`
|
|
404
410
|
* handler, which is how a short-lived CLI and a worker thread report.
|
|
@@ -448,6 +454,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
448
454
|
(function () {
|
|
449
455
|
const dir = process.env[${JSON.stringify(NODE_COVERAGE_DIR_ENV)}];
|
|
450
456
|
if (!dir) return;
|
|
457
|
+
const bunRuntime = !!process.versions.bun && process.env.SPECTEST_COVERAGE_RUNTIME === "bun";
|
|
451
458
|
let inspector, fs, path, crypto, url, wt, net;
|
|
452
459
|
try {
|
|
453
460
|
inspector = require("node:inspector");
|
|
@@ -468,19 +475,46 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
468
475
|
}
|
|
469
476
|
// An in-process session answers before post() returns.
|
|
470
477
|
function post(method, params) {
|
|
478
|
+
// Bun's callback API queues a microtask, including during process exit.
|
|
479
|
+
// Its synchronous return API is necessary for short-lived commands.
|
|
480
|
+
if (bunRuntime) return session.post(method, params || {});
|
|
471
481
|
let out = null;
|
|
472
482
|
session.post(method, params || {}, function (err, res) {
|
|
473
483
|
out = err ? null : res;
|
|
474
484
|
});
|
|
475
485
|
return out;
|
|
476
486
|
}
|
|
477
|
-
|
|
478
|
-
|
|
487
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
488
|
+
function fail(error) {
|
|
489
|
+
fs.writeFileSync(path.join(dir, "coverage-error-" + process.pid + ".txt"), String(error));
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
if (post("Profiler.enable") === null) throw new Error("coverage profiler unavailable");
|
|
493
|
+
if (!post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })) throw new Error("precise coverage unavailable");
|
|
494
|
+
if (bunRuntime && (!process.versions.bun || !wt.isMainThread)) throw new Error("Bun coverage requires a supported main-thread runtime");
|
|
495
|
+
} catch (error) { fail(error); throw error; }
|
|
496
|
+
let bunRangeAdjustment = 0;
|
|
497
|
+
if (bunRuntime) {
|
|
498
|
+
// Bun releases have exposed both JSC-inclusive and V8-exclusive range
|
|
499
|
+
// ends. Probe the actual runtime before app code loads: interpreting an
|
|
500
|
+
// inclusive closing brace as exclusive attributes its source-map token
|
|
501
|
+
// to startup, making untouched function bodies appear boot-executed.
|
|
502
|
+
const probe = "let x=0;\\nfunction spectestCoverageProbe(){return 1;}\\nspectestCoverageProbe();\\n//# sourceURL=spectest-coverage-probe";
|
|
503
|
+
eval(probe);
|
|
504
|
+
const measured = post("Profiler.takePreciseCoverage");
|
|
505
|
+
const script = measured && measured.result.find(s => s.url === "spectest-coverage-probe");
|
|
506
|
+
const fn = script && script.functions.find(f => f.ranges[0].startOffset === probe.indexOf("function"));
|
|
507
|
+
if (!fn) { const error = new Error("Bun precise coverage capability probe failed"); fail(error); throw error; }
|
|
508
|
+
bunRangeAdjustment = probe.indexOf("}") + 1 - fn.ranges[0].endOffset;
|
|
509
|
+
if (bunRangeAdjustment !== 0 && bunRangeAdjustment !== 1) {
|
|
510
|
+
const error = new Error("unsupported Bun coverage range semantics"); fail(error); throw error;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
479
513
|
|
|
480
514
|
// Attach before app scripts execute. Enabling only at capture loses the
|
|
481
515
|
// source of collected vm contexts (e.g. Temporal workflow bundles).
|
|
482
516
|
// Bound V8's collected-script cache; live sources remain owned by V8.
|
|
483
|
-
post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
|
|
517
|
+
if (!bunRuntime) post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
|
|
484
518
|
|
|
485
519
|
const GENERATED = new Map();
|
|
486
520
|
try {
|
|
@@ -499,6 +533,10 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
499
533
|
// can be reused by another process, vm context, or compilation.
|
|
500
534
|
const known = new Map();
|
|
501
535
|
let seq = 0;
|
|
536
|
+
const processToken = process.pid + "-" + crypto.randomBytes(6).toString("hex");
|
|
537
|
+
const liveMarker = path.join(dir, ".live-" + processToken + "-" + wt.threadId);
|
|
538
|
+
fs.writeFileSync(liveMarker, "");
|
|
539
|
+
fs.writeFileSync(path.join(dir, ".attached-" + (bunRuntime ? "bun" : "node")), "");
|
|
502
540
|
|
|
503
541
|
function isApp(u) {
|
|
504
542
|
return typeof u === "string" && u.startsWith("file://") && u.indexOf("/node_modules/") < 0 && u.indexOf("/.cache/") < 0 && u !== SELF;
|
|
@@ -674,6 +712,12 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
674
712
|
const kept = [];
|
|
675
713
|
const units = new Set();
|
|
676
714
|
for (const s of res.result) {
|
|
715
|
+
if (bunRuntime && bunRangeAdjustment && Array.isArray(s.functions)) {
|
|
716
|
+
const length = Math.max(...s.functions.flatMap(f => f.ranges.map(r => r.endOffset)));
|
|
717
|
+
for (const f of s.functions) for (const range of f.ranges) {
|
|
718
|
+
if (range.endOffset < length) range.endOffset += bunRangeAdjustment;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
677
721
|
if (!Array.isArray(s.functions) || !s.functions.some(ran)) continue;
|
|
678
722
|
if (isApp(s.url)) kept.push(s);
|
|
679
723
|
else if (isDep(s.url)) {
|
|
@@ -695,7 +739,18 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
695
739
|
fs.mkdirSync(MARKERS, { recursive: true });
|
|
696
740
|
} catch (e) {}
|
|
697
741
|
for (const s of fresh) {
|
|
698
|
-
|
|
742
|
+
let r;
|
|
743
|
+
if (bunRuntime) {
|
|
744
|
+
// Bun does not expose getScriptSource through the in-process API.
|
|
745
|
+
// Accept only precompiled, untranspiled bundles, never guess that
|
|
746
|
+
// disk TypeScript offsets describe executed JavaScript.
|
|
747
|
+
const source = fs.readFileSync(url.fileURLToPath(s.url), "utf8");
|
|
748
|
+
const extent = Math.max(...s.functions.flatMap(f => f.ranges.map(r => r.endOffset)));
|
|
749
|
+
if (!source.startsWith("// @bun\\n") || source.length !== extent) {
|
|
750
|
+
throw new Error("Bun coverage needs an unchanged bun build --target=bun bundle with source maps: " + s.url);
|
|
751
|
+
}
|
|
752
|
+
r = { scriptSource: source };
|
|
753
|
+
} else r = post("Debugger.getScriptSource", { scriptId: s.scriptId });
|
|
699
754
|
try {
|
|
700
755
|
const source = r && r.scriptSource;
|
|
701
756
|
if (GENERATED.has(s.url) && typeof source === "string") {
|
|
@@ -737,12 +792,12 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
737
792
|
};
|
|
738
793
|
}),
|
|
739
794
|
};
|
|
740
|
-
const name = ${JSON.stringify(NODE_COVERAGE_DUMP_PREFIX)} +
|
|
795
|
+
const name = ${JSON.stringify(NODE_COVERAGE_DUMP_PREFIX)} + processToken + "-" + wt.threadId + "-" + Date.now() + "-" + seq++ + ".json";
|
|
741
796
|
writeAtomic(path.join(dir, name), JSON.stringify(doc));
|
|
742
797
|
}
|
|
743
798
|
|
|
744
799
|
if (wt.isMainThread) {
|
|
745
|
-
const SOCK = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} +
|
|
800
|
+
const SOCK = path.join(dir, ${JSON.stringify(NODE_COVERAGE_SOCKET_PREFIX)} + processToken);
|
|
746
801
|
const server = net.createServer(function (conn) {
|
|
747
802
|
let buf = "";
|
|
748
803
|
conn.on("data", function (chunk) {
|
|
@@ -754,6 +809,7 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
754
809
|
take();
|
|
755
810
|
reply = "ok\\n";
|
|
756
811
|
} catch (err) {
|
|
812
|
+
fail(err);
|
|
757
813
|
reply = "error " + (err && err.message ? err.message : String(err)) + "\\n";
|
|
758
814
|
}
|
|
759
815
|
conn.end(reply);
|
|
@@ -774,7 +830,8 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
774
830
|
process.on("exit", function () {
|
|
775
831
|
try {
|
|
776
832
|
take();
|
|
777
|
-
|
|
833
|
+
fs.unlinkSync(liveMarker);
|
|
834
|
+
} catch (e) { fail(e); }
|
|
778
835
|
});
|
|
779
836
|
})();
|
|
780
837
|
`;
|
|
@@ -992,9 +1049,9 @@ export function node(options: NodeCoverageOptions = {}): CoverageAdapter {
|
|
|
992
1049
|
|
|
993
1050
|
/**
|
|
994
1051
|
* Ask every node process that holds a hook socket in `dir` for a dump.
|
|
995
|
-
*
|
|
996
|
-
*
|
|
997
|
-
*
|
|
1052
|
+
* An unanswered socket with a live marker means the process died without
|
|
1053
|
+
* a final report and fails collection. Stale sockets without live markers
|
|
1054
|
+
* are removed. Returns how many processes answered; error replies throw.
|
|
998
1055
|
*/
|
|
999
1056
|
export async function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Promise<number> {
|
|
1000
1057
|
const fs = await import("node:fs/promises");
|
|
@@ -1029,6 +1086,10 @@ export async function dumpAllNodeProcesses(dir: string, signal: AbortSignal): Pr
|
|
|
1029
1086
|
});
|
|
1030
1087
|
});
|
|
1031
1088
|
if (reply === null) {
|
|
1089
|
+
const pid = name.slice(NODE_COVERAGE_SOCKET_PREFIX.length);
|
|
1090
|
+
if ((await fs.readdir(dir)).some(marker => marker.startsWith(".live-" + pid + "-"))) {
|
|
1091
|
+
throw new Error(`coverage process ${name} exited without a final report`);
|
|
1092
|
+
}
|
|
1032
1093
|
// Nobody home: the process is gone. Clean up so the next capture
|
|
1033
1094
|
// does not knock again.
|
|
1034
1095
|
await fs.unlink(sock).catch(() => {});
|
package/src/daemon.ts
CHANGED
|
@@ -3873,7 +3873,11 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
|
|
|
3873
3873
|
for (const s of namedServices(l.project.environment)) byName.set(s.name, s);
|
|
3874
3874
|
for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
|
|
3875
3875
|
const all = [...byName.values()];
|
|
3876
|
-
const
|
|
3876
|
+
const covered = all.filter((s) => s.coverage !== undefined);
|
|
3877
|
+
const services = covered.filter(s => {
|
|
3878
|
+
const adapters = coverageAdapters(s.coverage);
|
|
3879
|
+
return !adapters.some(a => a.collect) || adapters.some(a => !a.collect);
|
|
3880
|
+
});
|
|
3877
3881
|
// The environment's own capture is unconditional (see above); the
|
|
3878
3882
|
// services' come from the ones that opted in, none included.
|
|
3879
3883
|
const environment = captureEnvironmentCoverage(all, baseline);
|
|
@@ -3922,7 +3926,23 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
|
|
|
3922
3926
|
return applyCoverageDelta(read, coverageReportsMode(svc.coverage), deltaReports);
|
|
3923
3927
|
}),
|
|
3924
3928
|
);
|
|
3925
|
-
|
|
3929
|
+
const components = await Promise.all(covered.flatMap(svc => coverageAdapters(svc.coverage)
|
|
3930
|
+
.filter(a => a.collect).map(async adapter => {
|
|
3931
|
+
const signal = AbortSignal.timeout(COVERAGE_CAPTURE_TIMEOUT_MS);
|
|
3932
|
+
try {
|
|
3933
|
+
return await adapter.collect!({
|
|
3934
|
+
...coverageCaptureContext(svc, signal, new Set()),
|
|
3935
|
+
projectFiles: () => walkProjectFiles(WORKSPACE),
|
|
3936
|
+
async readProjectFile(p) {
|
|
3937
|
+
try { return await fs.readFile(resolveProjectPath(p, { record: false }), "utf8"); }
|
|
3938
|
+
catch { return null; }
|
|
3939
|
+
},
|
|
3940
|
+
}, baseline);
|
|
3941
|
+
} catch (error) {
|
|
3942
|
+
return [{ service: `${svc.name}/${adapter.name}`, reports: [], error: String(error) }];
|
|
3943
|
+
}
|
|
3944
|
+
})));
|
|
3945
|
+
return [...captures, ...components.flat(), await environment];
|
|
3926
3946
|
}
|
|
3927
3947
|
|
|
3928
3948
|
/** Per-Browser rrweb session shipped to the control plane. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { BuildImage, ComponentImageBuild, ServiceConfig } from "../index.js";
|
|
3
3
|
|
|
4
|
-
export type KubernetesImage = string | (BuildImage & { reference: string });
|
|
4
|
+
export type KubernetesImage = string | (BuildImage & { reference: string; coverage?: import("../coverage.js").ServiceCoverage });
|
|
5
5
|
|
|
6
6
|
/** containerd/CRI use fully qualified names, including the default tag. */
|
|
7
7
|
export function canonicalImageReference(reference: string): string {
|
|
@@ -45,7 +45,10 @@ export function kubernetesImages(images: readonly KubernetesImage[]): { pulls: s
|
|
|
45
45
|
}
|
|
46
46
|
seen.set(reference, identity);
|
|
47
47
|
if (typeof image === "string") pulls.push(image);
|
|
48
|
-
else
|
|
48
|
+
else {
|
|
49
|
+
const { coverage: _coverage, ...build } = image;
|
|
50
|
+
builds.push({ reference, image: image.coverage === undefined ? image : build });
|
|
51
|
+
}
|
|
49
52
|
}
|
|
50
53
|
return { pulls, builds };
|
|
51
54
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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,
|
|
6
|
+
type CoverageCaptureContext, type CoverageInventoryContext, type ServiceCoverage } from "../coverage.js";
|
|
7
|
+
import { applyCoverageDelta, readCoverageDir, type ServiceCoverageCapture } from "./coverage.js";
|
|
8
|
+
|
|
9
|
+
export async function captureCoverageTarget(options: {
|
|
10
|
+
context: CoverageCaptureContext & CoverageInventoryContext;
|
|
11
|
+
coverage: ServiceCoverage;
|
|
12
|
+
root: string;
|
|
13
|
+
baseline: boolean;
|
|
14
|
+
inventoryAvailable?: boolean;
|
|
15
|
+
counterKey?: string;
|
|
16
|
+
}): Promise<ServiceCoverageCapture> {
|
|
17
|
+
const { context: ctx, coverage, root, baseline } = options;
|
|
18
|
+
const adapters = coverageAdapters(coverage);
|
|
19
|
+
const written = new Set<string>();
|
|
20
|
+
const delta = new Set<string>();
|
|
21
|
+
const context = { ...ctx, async writeReport(name: string, content: string) {
|
|
22
|
+
const resolved = path.resolve(ctx.reportDir, name);
|
|
23
|
+
if (!resolved.startsWith(path.resolve(ctx.reportDir) + path.sep)) throw new Error("coverage report escapes directory");
|
|
24
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
25
|
+
await fs.writeFile(resolved + ".tmp", content);
|
|
26
|
+
await fs.rename(resolved + ".tmp", resolved);
|
|
27
|
+
written.add(name);
|
|
28
|
+
} };
|
|
29
|
+
try {
|
|
30
|
+
ctx.signal.throwIfAborted();
|
|
31
|
+
const policies = await resolveCoverageSourceOptions(adapters, ctx, typeof coverage === "object" ? {
|
|
32
|
+
sourceRoot: coverage.sourceRoot, excludeSources: coverage.excludeSources,
|
|
33
|
+
} : undefined);
|
|
34
|
+
for (const adapter of adapters) {
|
|
35
|
+
if (adapter.collect || adapter.load) throw new Error(`adapter ${adapter.name} cannot run inside a container; attach browser coverage to the cluster service`);
|
|
36
|
+
written.clear();
|
|
37
|
+
await adapter.capture?.(context);
|
|
38
|
+
if (adapter.reports === "delta") for (const name of written) delta.add(name);
|
|
39
|
+
}
|
|
40
|
+
if (baseline && options.inventoryAvailable !== false) {
|
|
41
|
+
const units = new Set<string>();
|
|
42
|
+
for (const adapter of adapters) for (const unit of await adapter.inventory?.(context) ?? []) units.add(unit);
|
|
43
|
+
await context.writeReport("inventory.units", [...units].sort().join("\n"));
|
|
44
|
+
}
|
|
45
|
+
ctx.signal.throwIfAborted();
|
|
46
|
+
const capture = await readCoverageDir(ctx.service, ctx.reportDir);
|
|
47
|
+
if (baseline) await fs.rm(path.join(ctx.reportDir, "inventory.units"), { force: true });
|
|
48
|
+
capture.root = root;
|
|
49
|
+
attachCoverageSourceOptions(capture, policies);
|
|
50
|
+
const result = applyCoverageDelta({ ...capture, service: options.counterKey ?? capture.service }, coverageReportsMode(coverage), delta);
|
|
51
|
+
return { ...result, service: capture.service };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return { service: ctx.service, reports: [], error: String(error) };
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -175,7 +175,7 @@ export {
|
|
|
175
175
|
type CoverageLoadContext,
|
|
176
176
|
type ServiceCoverage,
|
|
177
177
|
} from "./coverage.js";
|
|
178
|
-
import { applyCoverageAdapters, validateCoverage, type ServiceCoverage } from "./coverage.js";
|
|
178
|
+
import { applyCoverageAdapters, coverageAdapters, validateCoverage, type ServiceCoverage } from "./coverage.js";
|
|
179
179
|
import { resolveExistingProjectPath } from "./project-files.js";
|
|
180
180
|
export { ca } from "./environment-ca.js";
|
|
181
181
|
|
|
@@ -1138,6 +1138,15 @@ function expandServiceGroups(input: InputServicesMap): ServicesMap {
|
|
|
1138
1138
|
// Rewrite relative dependsOn to final keys; a spread keeps function
|
|
1139
1139
|
// fields (helpers/setup) and the enumerable-symbol ingress decls.
|
|
1140
1140
|
const final: ServiceConfig = { ...svc, ...(part === entry.primary ? primaryOverrides : {}) };
|
|
1141
|
+
// Component-owned nested containers keep their collection when callers
|
|
1142
|
+
// add ordinary service coverage (for example browser coverage on k3s).
|
|
1143
|
+
const collectors = coverageAdapters(svc.coverage).filter(adapter => adapter.collect);
|
|
1144
|
+
if (part === entry.primary && collectors.length && primaryOverrides.coverage !== undefined) {
|
|
1145
|
+
const extra = primaryOverrides.coverage;
|
|
1146
|
+
final.coverage = { ...(typeof extra === "object" ? extra : {}), adapters: [
|
|
1147
|
+
...collectors, ...coverageAdapters(extra).filter(adapter => !collectors.includes(adapter)),
|
|
1148
|
+
] };
|
|
1149
|
+
}
|
|
1141
1150
|
if (final.dependsOn?.length) {
|
|
1142
1151
|
final.dependsOn = final.dependsOn.map((d) =>
|
|
1143
1152
|
partKeys.has(d) ? naming.key(d) : d,
|