@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.
@@ -0,0 +1,234 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { applyCoverageAdapters, coverageAdapters, type CoverageAdapter, type ServiceCoverage } from "../coverage.js";
5
+ import { coverageHostDir, COVERAGE_MAX_BYTES_PER_SERVICE, type ServiceCoverageCapture } from "../harness/coverage.js";
6
+ import { captureCoverageTarget } from "../harness/coverage-target.js";
7
+ import { canonicalImageReference, type KubernetesImage } from "../harness/component-images.js";
8
+ import type { ServiceConfig } from "../index.js";
9
+ import { WORKSPACE } from "../project-files.js";
10
+ import type { Patch, Pod } from "./k3s-trust.js";
11
+
12
+ const MOUNT = "/spectest/pod-coverage";
13
+ const ANNOTATION = "spectest.dev/coverage";
14
+ const hash = (value: string) => createHash("sha256").update(value).digest("hex").slice(0, 16);
15
+ const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
16
+ interface ImageInfo { aliases: string[]; root: string; env: Record<string, string> }
17
+ interface Instance { id: string; image: string; container: string; root: string; namespace: string }
18
+ interface Container {
19
+ name: string; image: string; workingDir?: string;
20
+ env?: { name: string; value?: string; valueFrom?: unknown }[];
21
+ volumeMounts?: { name: string; mountPath: string; [key: string]: unknown }[];
22
+ }
23
+ interface CoveragePod {
24
+ metadata?: { name?: string; annotations?: Record<string, string> };
25
+ spec?: { containers?: Container[]; initContainers?: Container[]; volumes?: { name: string; [key: string]: unknown }[] };
26
+ status?: { containerStatuses?: { name: string; state?: { running?: unknown } }[]; initContainerStatuses?: { name: string; state?: { running?: unknown } }[] };
27
+ }
28
+ export interface K3sCoverageDependencies {
29
+ image(reference: string): Promise<ImageInfo>;
30
+ files(namespace: string, name: string, data: Record<string, string>): Promise<void>;
31
+ }
32
+
33
+ /** One component-owned bridge; runtime adapters remain unaware of Kubernetes. */
34
+ export function k3sCoverage(images: readonly KubernetesImage[]) {
35
+ const declarations = images.flatMap(image => typeof image === "string" || image.coverage === undefined ? [] : [{
36
+ reference: canonicalImageReference(image.reference), coverage: image.coverage,
37
+ }]);
38
+ for (const image of declarations) {
39
+ const adapters = coverageAdapters(image.coverage);
40
+ if (adapters.some(adapter => adapter.load || adapter.collect)) throw new Error("k3s image coverage accepts container adapters; put browser coverage on the cluster service");
41
+ applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage });
42
+ }
43
+ const metadata = new Map<string, ImageInfo>();
44
+ const service = (cluster: string, reference: string) => `${cluster}/image-${hash(reference)}`;
45
+ return {
46
+ enabled: declarations.length > 0,
47
+ volume(cluster: string) {
48
+ return { source: coverageHostDir(WORKSPACE, cluster), target: MOUNT, reachExempt: true };
49
+ },
50
+ async mutate(cluster: string, podInput: Pod, namespace: string, dryRun: boolean, ephemeral: boolean, uid: string, deps: K3sCoverageDependencies): Promise<Patch[]> {
51
+ if (!declarations.length || ephemeral) return [];
52
+ const pod = podInput as CoveragePod;
53
+ if (!pod.spec) return [];
54
+ const existing = pod.metadata?.annotations?.[ANNOTATION];
55
+ if (existing) {
56
+ if (JSON.parse(existing).admission === uid) return [];
57
+ throw new Error("remove inherited spectest.dev/coverage annotations before creating another Pod");
58
+ }
59
+ const patch: Patch[] = [];
60
+ const volumes = [...(pod.spec.volumes ?? [])];
61
+ const instances: Instance[] = [];
62
+ const namedVolume = (stem: string, volume: Record<string, unknown>) => {
63
+ let name = stem;
64
+ for (let i = 1; volumes.some(v => v.name === name); i++) name = `${stem}-${i}`;
65
+ volumes.push({ name, ...volume }); return name;
66
+ };
67
+ for (const group of ["initContainers", "containers"] as const) {
68
+ for (const [index, container] of (pod.spec[group] ?? []).entries()) {
69
+ const requested = canonicalImageReference(container.image);
70
+ let declaration = declarations.find(image => image.reference === requested);
71
+ // Tangent deploys the prepared image by digest. Resolve aliases from
72
+ // CRI's actual image record, never by stripping a repository tag.
73
+ if (!declaration && requested.includes("@")) {
74
+ for (const candidate of declarations) {
75
+ let info = metadata.get(candidate.reference);
76
+ if (!info) metadata.set(candidate.reference, info = await deps.image(candidate.reference));
77
+ if (info.aliases.includes(requested)) { declaration = candidate; break; }
78
+ }
79
+ }
80
+ if (!declaration) continue;
81
+ let info = metadata.get(declaration.reference);
82
+ if (!info) metadata.set(declaration.reference, info = await deps.image(declaration.reference));
83
+ const id = hash(`${uid}/${group}/${container.name}`);
84
+ const name = service(cluster, declaration.reference);
85
+ const env = { ...info.env };
86
+ for (const entry of container.env ?? []) if (entry.value !== undefined) env[entry.name] = entry.value;
87
+ const configured = applyCoverageAdapters<ServiceConfig>(name, { image: { type: "registry", reference: container.image }, env, coverage: declaration.coverage });
88
+ if (Object.keys(configured).some(key => !["image", "env", "coverage", "files"].includes(key))) {
89
+ throw new Error("Kubernetes coverage adapters may inject env and files only; configure the workload's command and mounts in its manifest");
90
+ }
91
+ const changed = Object.entries(configured.env ?? {}).filter(([key, value]) => env[key] !== value);
92
+ for (const [key] of changed) if (container.env?.some(e => e.name === key && e.valueFrom)) throw new Error(`cannot inject coverage into dynamic ${key}`);
93
+ const mounts = [...(container.volumeMounts ?? [])];
94
+ const addMount = (mountPath: string, mount: Record<string, unknown>) => {
95
+ if (mounts.some(m => mountPath === m.mountPath || mountPath.startsWith(m.mountPath.replace(/\/$/, "") + "/"))) throw new Error(`coverage mount conflicts with ${mountPath}`);
96
+ mounts.push({ mountPath, ...mount } as typeof mounts[number]);
97
+ };
98
+ const volume = namedVolume(`spectest-cov-${id}`, { hostPath: { path: `${MOUNT}/.pods/${id}`, type: "Directory" } });
99
+ addMount("/spectest/coverage", { name: volume });
100
+ const files = configured.files ?? [];
101
+ if (files.some(file => file.user !== undefined || file.group !== undefined || (file.mode !== undefined && !["444", "0444", "644", "0644"].includes(file.mode)))) {
102
+ throw new Error("Kubernetes coverage hook files must be world-readable and need no custom ownership");
103
+ }
104
+ if (files.length) {
105
+ const data = Object.fromEntries(files.map((file, index) => [`file${index}`, file.content]));
106
+ const configMap = `spectest-coverage-${hash(JSON.stringify(data))}`;
107
+ if (!dryRun) await deps.files(namespace, configMap, data);
108
+ const fileVolume = namedVolume(`spectest-hooks-${id}`, { configMap: { name: configMap, defaultMode: 0o444 } });
109
+ files.forEach((file, index) => addMount(file.path, { name: fileVolume, subPath: `file${index}`, readOnly: true }));
110
+ }
111
+ const instance: Instance = { id, image: declaration.reference, namespace, container: container.name, root: container.workingDir || info.root || "/" };
112
+ if (!dryRun) {
113
+ const dir = path.join(coverageHostDir(WORKSPACE, cluster), ".pods", id);
114
+ await fs.mkdir(dir, { recursive: true, mode: 0o777 });
115
+ await fs.chmod(dir, 0o777);
116
+ await fs.writeFile(path.join(dir, ".instance.json"), JSON.stringify(instance));
117
+ }
118
+ instances.push(instance);
119
+ patch.push({ op: "add", path: `/spec/${group}/${index}/env`, value: [
120
+ ...(container.env ?? []).filter(e => !changed.some(([key]) => key === e.name)),
121
+ ...changed.map(([name, value]) => ({ name, value })),
122
+ ] }, { op: "add", path: `/spec/${group}/${index}/volumeMounts`, value: mounts });
123
+ }
124
+ }
125
+ if (!instances.length) return [];
126
+ patch.push({ op: "add", path: "/spec/volumes", value: volumes });
127
+ patch.push({ op: "add", path: "/metadata/annotations", value: { ...pod.metadata?.annotations,
128
+ [ANNOTATION]: JSON.stringify({ admission: uid, instances: instances.map(i => i.id) }),
129
+ } });
130
+ return patch;
131
+ },
132
+ adapter(): CoverageAdapter {
133
+ return {
134
+ name: "k3s-containers",
135
+ // Include nested policy AND injected hooks in the warm identity. An
136
+ // adapter's functions would otherwise disappear from JSON.stringify.
137
+ toJSON() { 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
+ async collect(ctx, baseline) {
142
+ const root = path.join(ctx.reportDir, ".pods");
143
+ let entries: string[];
144
+ try { entries = await fs.readdir(root); } catch (error: any) { if (error.code !== "ENOENT") throw error; entries = []; }
145
+ // Native adapters flush through the shared Unix sockets, including
146
+ // after Pod deletion. Only inventory or command adapters need the API.
147
+ let pods: Promise<CoveragePod[]> | undefined;
148
+ const findPod = async (id: string) => {
149
+ pods ??= ctx.exec("kubectl get pods -A -o json --request-timeout=10s")
150
+ .then(result => JSON.parse(result.stdout).items as CoveragePod[]);
151
+ return (await pods).find(p => {
152
+ const ids = p.metadata?.annotations?.[ANNOTATION];
153
+ return ids && (JSON.parse(ids).instances as string[]).includes(id);
154
+ });
155
+ };
156
+ const captures = new Map<string, ServiceCoverageCapture>();
157
+ const inventories = new Map<string, Set<string>>();
158
+ for (const declaration of declarations) captures.set(declaration.reference, { service: service(ctx.service, declaration.reference), reports: [] });
159
+ const collectInstance = async (id: string) => {
160
+ ctx.signal.throwIfAborted();
161
+ if (!/^[a-f0-9]{16}$/.test(id)) throw new Error("invalid coverage instance directory");
162
+ const dir = path.join(root, id);
163
+ const instance = JSON.parse(await fs.readFile(path.join(dir, ".instance.json"), "utf8")) as Instance;
164
+ const declaration = declarations.find(d => d.reference === instance.image);
165
+ if (!declaration) throw new Error(`coverage image no longer declared: ${instance.image}`);
166
+ const adapters = coverageAdapters(declaration.coverage);
167
+ const livePod = async () => {
168
+ const pod = await findPod(id);
169
+ const statuses = [...(pod?.status?.containerStatuses ?? []), ...(pod?.status?.initContainerStatuses ?? [])];
170
+ return statuses.some(status => status.name === instance.container && status.state?.running) ? pod : undefined;
171
+ };
172
+ for (const adapter of adapters) {
173
+ if (adapter.name === "node") {
174
+ try { await fs.access(path.join(dir, `.attached-${adapter.name}`)); }
175
+ catch { throw new Error(`coverage hook never attached to ${instance.namespace}/${instance.container} (${adapter.name})`); }
176
+ }
177
+ }
178
+ const captured = await captureCoverageTarget({
179
+ coverage: declaration.coverage, root: instance.root, baseline,
180
+ inventoryAvailable: baseline && adapters.some(a => a.inventory) ? !!await livePod() : false,
181
+ counterKey: `${service(ctx.service, instance.image)}/${id}`,
182
+ context: { ...ctx, service: service(ctx.service, instance.image), reportDir: dir,
183
+ exec: async command => {
184
+ const pod = await livePod();
185
+ if (!pod?.metadata?.name) throw new Error(`coverage command requires live container ${instance.container}`);
186
+ return ctx.exec(`kubectl exec -n ${quote(instance.namespace)} ${quote(pod.metadata.name)} -c ${quote(instance.container)} -- sh -c ${quote(command)}`);
187
+ },
188
+ },
189
+ });
190
+ return { id, instance, captured };
191
+ };
192
+ // Bound simultaneous LLVM exports and API execs. Drain all workers
193
+ // even on error so no collector can race the following VM snapshot.
194
+ const pending = entries.sort();
195
+ const results = new Map<string, Awaited<ReturnType<typeof collectInstance>>>();
196
+ let cursor = 0;
197
+ const workers = await Promise.allSettled(Array.from({ length: Math.min(8, pending.length) }, async () => {
198
+ while (cursor < pending.length) {
199
+ const id = pending[cursor++];
200
+ results.set(id, await collectInstance(id));
201
+ }
202
+ }));
203
+ const failure = workers.find(worker => worker.status === "rejected");
204
+ if (failure?.status === "rejected") throw failure.reason;
205
+ for (const id of pending) {
206
+ const { instance, captured } = results.get(id)!;
207
+ const aggregate = captures.get(instance.image)!;
208
+ if (captured.error) aggregate.error = [aggregate.error, `${instance.namespace}/${instance.container}: ${captured.error}`].filter(Boolean).join("; ");
209
+ if (aggregate.root && aggregate.root !== instance.root) {
210
+ aggregate.error = "coverage containers from one image have different working directories; use a common workingDir for this image's covered containers";
211
+ }
212
+ aggregate.root = instance.root;
213
+ for (const report of captured.reports) {
214
+ if (report.name === "inventory.units") {
215
+ let units = inventories.get(instance.image);
216
+ if (!units) inventories.set(instance.image, units = new Set());
217
+ for (const unit of report.content.split("\n")) if (unit) units.add(unit);
218
+ } else aggregate.reports.push({ ...report, name: `${id}/${report.name}` });
219
+ }
220
+ }
221
+ for (const [image, units] of inventories) captures.get(image)!.reports.push({ name: "inventory.units", content: [...units].sort().join("\n") });
222
+ for (const capture of captures.values()) {
223
+ if (!capture.reports.length && !capture.error) capture.reports.push({ name: "empty.lcov", content: "TN:\n" });
224
+ if (capture.reports.reduce((bytes, report) => bytes + Buffer.byteLength(report.content), 0) > COVERAGE_MAX_BYTES_PER_SERVICE) {
225
+ capture.reports = [];
226
+ capture.error = "Kubernetes image coverage exceeds the per-service report size limit";
227
+ }
228
+ }
229
+ return [...captures.values()];
230
+ },
231
+ } as CoverageAdapter;
232
+ },
233
+ };
234
+ }
@@ -14,7 +14,7 @@ interface Container {
14
14
  env?: { name: string; value?: string; valueFrom?: unknown }[];
15
15
  volumeMounts?: { name: string; mountPath: string; subPath?: string; readOnly?: boolean }[];
16
16
  }
17
- interface Pod {
17
+ export interface Pod {
18
18
  metadata?: { annotations?: Record<string, string> };
19
19
  spec?: {
20
20
  os?: { name: string };
@@ -22,7 +22,7 @@ interface Pod {
22
22
  containers?: Container[]; initContainers?: Container[]; ephemeralContainers?: Container[];
23
23
  };
24
24
  }
25
- interface Patch { op: "add"; path: string; value: unknown }
25
+ export interface Patch { op: "add"; path: string; value: unknown }
26
26
  export const TRUST_OPT_OUT = "spectest.dev/inject-ca";
27
27
 
28
28
  export function podTrustPatch(pod: Pod, configMap: string, ephemeral = false): Patch[] {
@@ -67,6 +67,8 @@ export interface TrustDependencies {
67
67
  /** Ensure this immutable ConfigMap exists in the requesting namespace. */
68
68
  ensureBundle(namespace: string, name: string, bundle: string): Promise<void>;
69
69
  apply(configuration: unknown): Promise<void>;
70
+ /** Component-owned pod instrumentation runs after trust patching. */
71
+ mutatePod?(pod: Pod, namespace: string, dryRun: boolean, ephemeral: boolean, uid: string): Promise<Patch[]>;
70
72
  }
71
73
  const servers = new Map<string, Server>();
72
74
 
@@ -91,6 +93,16 @@ export async function installPodTrust(options: {
91
93
  res.writeHead(400).end("invalid Pod admission request"); return;
92
94
  }
93
95
  const patch = podTrustPatch(request.object, configMap, request.subResource === "ephemeralcontainers");
96
+ if (deps.mutatePod) {
97
+ const pod = structuredClone(request.object);
98
+ for (const operation of patch) {
99
+ const keys = operation.path.slice(1).split("/");
100
+ const key = keys.pop()!;
101
+ const parent = keys.reduce((value: any, key) => value[key], pod as any);
102
+ if (key === "-") parent.push(operation.value); else parent[key] = operation.value;
103
+ }
104
+ patch.push(...await deps.mutatePod(pod, request.namespace, !!request.dryRun, request.subResource === "ephemeralcontainers", request.uid));
105
+ }
94
106
  // Dry runs return the same patch but perform no API writes.
95
107
  if (patch.length && !request.dryRun) await deps.ensureBundle(request.namespace, configMap, options.bundle);
96
108
  res.setHeader("Content-Type", "application/json");
@@ -1,6 +1,7 @@
1
1
  import { isIP } from "node:net";
2
2
  import { waitForK3sNodes } from "./k3s-nodes.js";
3
3
  import { installPodTrust } from "./k3s-trust.js";
4
+ import { k3sCoverage } from "./k3s-coverage.js";
4
5
  import { waitForAll } from "../harness/concurrency.js";
5
6
  import { CONTENT_BLOBS, REGISTRY_CONTENT, REGISTRY_DATA, REGISTRY_IMAGE, registryDirectory, seedRegistry } from "../harness/registry-seed.js";
6
7
  import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
@@ -1969,6 +1970,7 @@ export function k3s(opts: K3sOptions = {}) {
1969
1970
  function createK3sCluster(opts: K3sOptions, count: number) {
1970
1971
  const version = opts.version ?? DEFAULT_K3S_VERSION;
1971
1972
  const images = kubernetesImages(opts.images ?? []);
1973
+ const coverage = k3sCoverage(opts.images ?? []);
1972
1974
  const extra = opts.extraArgs ?? [];
1973
1975
  assertUsableExtraArgs(extra);
1974
1976
  const registryEnabled = opts.registry !== false;
@@ -2132,6 +2134,7 @@ function createK3sCluster(opts: K3sOptions, count: number) {
2132
2134
  volumes: [{ name: `spectest-k3s-${cluster}-join`, target: JOIN_DIRECTORY }, ...(sticky
2133
2135
  ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
2134
2136
  : [{ target: CLUSTER_STORE_TARGET }]),
2137
+ ...(coverage.enabled ? [coverage.volume(cluster)] : []),
2135
2138
  ...(server && publishBuilds.length ? [
2136
2139
  { source: registryDirectory(SELF_SERVICE_TOKEN), target: REGISTRY_DATA },
2137
2140
  { source: contentDirectory, target: REGISTRY_CONTENT, readOnly: true },
@@ -2140,6 +2143,7 @@ function createK3sCluster(opts: K3sOptions, count: number) {
2140
2143
  files: [
2141
2144
  ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
2142
2145
  ],
2146
+ ...(server && coverage.enabled ? { coverage: { adapters: [coverage.adapter()] } } : {}),
2143
2147
  // Agents start before the server and wait for its join token. The group
2144
2148
  // setup below gates readiness on every actual Kubernetes Node, including
2145
2149
  // the server, rather than treating container startup as cluster readiness.
@@ -2167,11 +2171,33 @@ function createK3sCluster(opts: K3sOptions, count: number) {
2167
2171
  const nodes = nodeNames(name);
2168
2172
  if (images.builds.length && !runtime) throw new Error(`k3s(${name}): prepared image inputs are missing`);
2169
2173
  const admission = (async () => {
2174
+ if (coverage.enabled && (!runtime?.caBundle || !caPresent())) throw new Error(`k3s(${name}): coverage injection requires the environment CA`);
2170
2175
  if (runtime?.caBundle && caPresent()) {
2171
2176
  const { cert, key } = await issueIngressCert([runtime.gateway]);
2172
2177
  await installPodTrust({ name, gateway: runtime.gateway, cert, key,
2173
2178
  ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
2174
2179
  }, {
2180
+ mutatePod: (pod, namespace, dryRun, ephemeral, uid) => coverage.mutate(name, pod, namespace, dryRun, ephemeral, uid, {
2181
+ async image(reference) {
2182
+ const result = await exec(name, ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock", "inspecti", reference]);
2183
+ if (result.exitCode !== 0) throw new Error(`coverage image metadata: ${result.stderr}`);
2184
+ const record = JSON.parse(result.stdout);
2185
+ const config = record.info?.imageSpec?.config;
2186
+ if (!config) throw new Error(`coverage image ${reference} has no OCI configuration`);
2187
+ return { aliases: record.status?.repoDigests ?? [], root: config.WorkingDir || "/",
2188
+ env: Object.fromEntries((config.Env ?? []).map((entry: string) => { const split = entry.indexOf("="); return [entry.slice(0, split), entry.slice(split + 1)]; })),
2189
+ };
2190
+ },
2191
+ async files(namespace, name, data) {
2192
+ try {
2193
+ await helpers.client.core.createNamespacedConfigMap({ namespace, body: { metadata: { name, namespace }, immutable: true, data } });
2194
+ } catch (error) {
2195
+ const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name })).unwrap();
2196
+ if (!existing.immutable || Object.keys(existing.data ?? {}).length !== Object.keys(data).length ||
2197
+ Object.entries(data).some(([key, value]) => existing.data?.[key] !== value)) throw error;
2198
+ }
2199
+ },
2200
+ }),
2175
2201
  apply: async configuration => { await helpers.apply(JSON.stringify(configuration)); },
2176
2202
  ensureBundle: async (namespace, configMap, bundle) => {
2177
2203
  try {
@@ -0,0 +1,102 @@
1
+ // Copy to your test build as spectest-coverage.rs and include behind a coverage
2
+ // feature. Call start()? at the beginning of main in EACH instrumented binary.
3
+ // No Spectest service, compiler or network connection is needed at runtime.
4
+ use std::{ffi::CString, fs, io::{self, BufRead, Read, Write}, os::unix::net::UnixListener,
5
+ path::PathBuf, process::Command, sync::{Mutex, OnceLock}, time::{SystemTime, UNIX_EPOCH}};
6
+
7
+ unsafe extern "C" {
8
+ fn __llvm_profile_get_size_for_buffer() -> u64;
9
+ fn __llvm_profile_write_buffer(buffer: *mut u8) -> i32;
10
+ fn __llvm_profile_set_filename(filename: *const std::ffi::c_char);
11
+ fn atexit(callback: extern "C" fn()) -> i32;
12
+ }
13
+
14
+ struct Collector { dir: PathBuf, id: String, socket: PathBuf, binary: PathBuf, lock: Mutex<()> }
15
+ static COLLECTOR: OnceLock<Collector> = OnceLock::new();
16
+
17
+ fn failure(error: impl std::fmt::Display) {
18
+ if let Some(c) = COLLECTOR.get() {
19
+ let _ = fs::write(c.dir.join(format!("rust-error-{}.txt", c.id)), error.to_string());
20
+ }
21
+ }
22
+
23
+ fn tool(name: &str, default: &str, args: &[&std::ffi::OsStr]) -> io::Result<Vec<u8>> {
24
+ let output = Command::new(std::env::var_os(name).unwrap_or_else(|| default.into())).args(args).output()?;
25
+ if !output.status.success() {
26
+ return Err(io::Error::other(format!("{default}: {}", String::from_utf8_lossy(&output.stderr))));
27
+ }
28
+ Ok(output.stdout)
29
+ }
30
+
31
+ fn capture() -> io::Result<()> {
32
+ let c = COLLECTOR.get().expect("coverage initialized");
33
+ let _guard = c.lock.lock().map_err(|_| io::Error::other("coverage lock poisoned"))?;
34
+ let raw = c.dir.join(format!(".rust/{}.profraw", c.id));
35
+ let data = c.dir.join(format!(".rust/{}.profdata", c.id));
36
+ // A buffer snapshot overwrites this process's profile. write_file() with
37
+ // merge patterns would repeatedly add its inherited counters to itself.
38
+ let mut buffer = vec![0u8; unsafe { __llvm_profile_get_size_for_buffer() } as usize];
39
+ if unsafe { __llvm_profile_write_buffer(buffer.as_mut_ptr()) } != 0 {
40
+ return Err(io::Error::other("LLVM could not snapshot counters"));
41
+ }
42
+ fs::write(&raw, buffer)?;
43
+ tool("SPECTEST_LLVM_PROFDATA", "llvm-profdata", &["merge".as_ref(), "-sparse".as_ref(), raw.as_os_str(), "-o".as_ref(), data.as_os_str()])?;
44
+ let ignore = std::env::var("SPECTEST_RUST_IGNORE").unwrap_or_else(|_| "(/rustc/|/\\.cargo/registry/|/spectest-coverage\\.rs$)".into());
45
+ let report = tool("SPECTEST_LLVM_COV", "llvm-cov", &["export".as_ref(), "-format=lcov".as_ref(), "-instr-profile".as_ref(), data.as_os_str(),
46
+ c.binary.as_os_str(), format!("-ignore-filename-regex={ignore}").as_ref()])?;
47
+ if !report.starts_with(b"SF:") && !report.starts_with(b"TN:") {
48
+ return Err(io::Error::other("LLVM exported no LCOV source records"));
49
+ }
50
+ let mut report = String::from_utf8(report).map_err(io::Error::other)?;
51
+ if let Ok(root) = std::env::var("SPECTEST_RUST_BUILD_ROOT") {
52
+ let prefix = format!("SF:{}/", root.trim_end_matches('/'));
53
+ report = report.lines().map(|line| match line.strip_prefix(&prefix) {
54
+ Some(relative) => format!("SF:{relative}\n"), None => format!("{line}\n"),
55
+ }).collect();
56
+ }
57
+ let temporary = c.dir.join(format!(".rust/{}.lcov.tmp", c.id));
58
+ fs::write(&temporary, report)?;
59
+ fs::rename(temporary, c.dir.join(format!("rust-{}.lcov", c.id)))?;
60
+ Ok(())
61
+ }
62
+
63
+ extern "C" fn finish() {
64
+ if let Err(error) = capture() { failure(error); }
65
+ if let Some(c) = COLLECTOR.get() { let _ = fs::remove_file(&c.socket); }
66
+ }
67
+
68
+ /// Disabled when SPECTEST_COVERAGE_DIR is absent. Requires Unix and LLVM
69
+ /// instrumentation at link time; gate this module behind your coverage build.
70
+ pub fn start() -> io::Result<()> {
71
+ let Some(dir) = std::env::var_os("SPECTEST_COVERAGE_DIR") else { return Ok(()); };
72
+ if COLLECTOR.get().is_some() { return Ok(()); }
73
+ let dir = PathBuf::from(dir);
74
+ fs::create_dir_all(dir.join(".rust"))?;
75
+ let nonce = SystemTime::now().duration_since(UNIX_EPOCH).map_err(io::Error::other)?.as_nanos();
76
+ let id = format!("{:x}-{nonce:x}", std::process::id());
77
+ let socket = dir.join(format!(".rust-ctl-{id}"));
78
+ let listener = UnixListener::bind(&socket)?;
79
+ let binary = std::env::current_exe()?;
80
+ let exit_path = CString::new(dir.join(format!(".rust/{id}.exit.profraw")).as_os_str().as_encoded_bytes())?;
81
+ // The profiler may retain the filename until process exit.
82
+ unsafe { __llvm_profile_set_filename(exit_path.into_raw()); }
83
+ COLLECTOR.set(Collector { dir, id, socket, binary, lock: Mutex::new(()) }).map_err(|_| io::Error::other("coverage already initialized"))?;
84
+ if unsafe { atexit(finish) } != 0 { return Err(io::Error::other("could not register coverage exit handler")); }
85
+ // Establish a report before readiness; an uninstrumented/mismatched build
86
+ // must fail before it can supply an apparently empty baseline.
87
+ if let Err(error) = capture() { failure(&error); return Err(error); }
88
+ std::thread::Builder::new().name("spectest-coverage".into()).spawn(move || {
89
+ for connection in listener.incoming() {
90
+ let mut stream = match connection { Ok(stream) => stream, Err(error) => { failure(error); break; } };
91
+ let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5)));
92
+ let mut request = String::new();
93
+ let result = io::BufReader::new(&stream).take(32).read_line(&mut request)
94
+ .and_then(|_| if request == "dump\n" { capture() } else { Err(io::Error::other("invalid coverage request")) });
95
+ match result {
96
+ Ok(()) => { let _ = stream.write_all(b"ok\n"); }
97
+ Err(error) => { failure(&error); let _ = writeln!(stream, "error {error}"); }
98
+ }
99
+ }
100
+ })?;
101
+ Ok(())
102
+ }
@@ -0,0 +1,103 @@
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 { bun, applyCoverageAdapters, NODE_COVERAGE_HOOK } from "./coverage";
6
+ import { readCoverageDir, encodeCoverageBundle } from "./harness/coverage";
7
+
8
+ async function checked(args: string[], cwd: string, env?: Record<string, string | undefined>) {
9
+ const child = Bun.spawn(args, { cwd, env: { ...process.env, ...env }, stdout: "pipe", stderr: "pipe" });
10
+ const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
11
+ if (code) throw new Error(`${args.join(" ")}: ${stderr}`);
12
+ return stdout;
13
+ }
14
+
15
+ test("Bun native coverage preserves bundle maps, captures exit, and rejects unmappable runtime transpilation", async () => {
16
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "bun-cov-"));
17
+ const reports = path.join(root, "reports");
18
+ await fs.mkdir(reports);
19
+ try {
20
+ await fs.writeFile(path.join(root, "hook.cjs"), NODE_COVERAGE_HOOK);
21
+ await fs.writeFile(path.join(root, "app.ts"), `export function choice(x: boolean) {\n if(x) return 'left';\n return 'right';\n}\nconsole.log(choice(true));\n`);
22
+ await checked([process.execPath, "build", "--target=bun", "--sourcemap=external", "--outdir=dist", "app.ts"], root);
23
+ const env = { BUN_OPTIONS: `--preload ${root}/hook.cjs`, SPECTEST_COVERAGE_DIR: reports, SPECTEST_COVERAGE_RUNTIME: "bun" };
24
+ expect(await checked([process.execPath, "dist/app.js"], root, env)).toContain("left");
25
+ const adapter = bun();
26
+ const context = { service: "bun", reportDir: reports, signal: AbortSignal.timeout(5000), exec: async () => ({ stdout: "", stderr: "" }),
27
+ writeReport: (name: string, content: string) => fs.writeFile(path.join(reports, name), content) };
28
+ await adapter.capture!(context);
29
+ const capture = await readCoverageDir("bun", reports);
30
+ expect(capture.error).toBeUndefined();
31
+ const scripts = capture.reports.filter(r => r.name.startsWith("scripts/")).map(r => JSON.parse(r.content).spectestScript);
32
+ expect(scripts).toHaveLength(1);
33
+ expect(scripts[0].sourceMap.data.sources.join()).toContain("app.ts");
34
+ expect(scripts[0].sourceMap.data.sourcesContent).toBeUndefined();
35
+ const dump = capture.reports.find(r => r.name.startsWith("node-"))!;
36
+ expect(JSON.parse(dump.content).result[0].functions.length).toBeGreaterThan(0);
37
+ await adapter.capture!(context);
38
+ expect((await readCoverageDir("bun", reports)).reports.some(r => r.name.startsWith("node-"))).toBe(false);
39
+ await checked([process.execPath, "app.ts"], root, env);
40
+ expect((await readCoverageDir("bun", reports)).error).toContain("neither");
41
+ const config = applyCoverageAdapters("cli", { image: "oven/bun", env: { BUN_OPTIONS: "--smol" }, coverage: { adapters: [adapter] } });
42
+ expect(config.env.BUN_OPTIONS).toContain("--smol --preload");
43
+ } finally { await fs.rm(root, { recursive: true, force: true }); }
44
+ }, 20000);
45
+
46
+ test("Bun live captures reset execution counts between calls", async () => {
47
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "bun-live-"));
48
+ let child: ReturnType<typeof Bun.spawn> | undefined;
49
+ try {
50
+ await fs.mkdir(path.join(root, "reports"));
51
+ await fs.writeFile(path.join(root, "hook.cjs"), NODE_COVERAGE_HOOK);
52
+ await fs.writeFile(path.join(root, "server.ts"), `import { createInterface } from 'node:readline';\nfunction left() {\n return 41;\n}\nfunction right() {\n return 42;\n}\ncreateInterface({input:process.stdin}).on('line', line => console.log(line === 'left' ? left() : right()));\nconsole.log('ready');\n`);
53
+ await checked([process.execPath, "build", "--target=bun", "--sourcemap=external", "--outdir=dist", "server.ts"], root);
54
+ child = Bun.spawn([process.execPath, "dist/server.js"], { cwd: root, stdin: "pipe", stdout: "pipe", stderr: "pipe", env: {
55
+ ...process.env, BUN_OPTIONS: `--preload ${root}/hook.cjs`, SPECTEST_COVERAGE_DIR: `${root}/reports`, SPECTEST_COVERAGE_RUNTIME: "bun",
56
+ } });
57
+ const reader = (child.stdout as ReadableStream<Uint8Array>).getReader();
58
+ expect(new TextDecoder().decode((await reader.read()).value)).toContain("ready");
59
+ const ctx = { service: "live", reportDir: `${root}/reports`, signal: AbortSignal.timeout(5000), exec: async () => ({ stdout: "", stderr: "" }),
60
+ writeReport: (name: string, content: string) => fs.writeFile(path.join(root, "reports", name), content) };
61
+ const adapter = bun();
62
+ await adapter.capture!(ctx);
63
+ const setup = await readCoverageDir("live", ctx.reportDir);
64
+ const compiled = await fs.readFile(path.join(root, "dist/server.js"), "utf8");
65
+ const descriptor = JSON.parse(setup.reports.find(r => r.name.startsWith("scripts/"))!.content).spectestScript;
66
+ for (const name of ["left", "right"]) {
67
+ const start = compiled.indexOf(`function ${name}(`);
68
+ const range = descriptor.functions.find((f: [string, number, number]) => f[1] === start);
69
+ expect(range).toBeDefined();
70
+ expect(compiled[range[2] - 1]).toBe("}");
71
+ }
72
+ (child.stdin as { write(value: string): void }).write("left\n");
73
+ expect(new TextDecoder().decode((await reader.read()).value)).toContain("41");
74
+ await adapter.capture!(ctx);
75
+ const first = await readCoverageDir("live", ctx.reportDir);
76
+ if (process.env.SPECTEST_RUNTIME_COVERAGE_EVIDENCE) {
77
+ const output = path.join(process.env.SPECTEST_RUNTIME_COVERAGE_EVIDENCE, "bun");
78
+ await fs.mkdir(output, { recursive: true });
79
+ await fs.writeFile(path.join(output, "setup.json.gz"), encodeCoverageBundle("__setup__", [{ ...setup, root }]));
80
+ await fs.writeFile(path.join(output, "left.json.gz"), encodeCoverageBundle("left", [{ ...first, root }]));
81
+ await fs.writeFile(path.join(output, "tree.txt"), "server.ts\n");
82
+ await fs.writeFile(path.join(output, "lines.json"), JSON.stringify({ "server.ts": 9 }));
83
+ }
84
+ expect(first.error).toBeUndefined();
85
+ const functions = first.reports.filter(r => r.name.startsWith("node-")).flatMap(r => JSON.parse(r.content).result.flatMap((s: any) => s.functions));
86
+ expect(functions.length).toBeGreaterThan(0);
87
+ await adapter.capture!(ctx);
88
+ const idle = await readCoverageDir("live", ctx.reportDir);
89
+ // No executed application ranges survive an idle take.
90
+ const idleFunctions = idle.reports.filter(r => r.name.startsWith("node-")).flatMap(r => JSON.parse(r.content).result.flatMap((s: any) => s.functions));
91
+ expect(idleFunctions).toHaveLength(0);
92
+ (child.stdin as { write(value: string): void }).write("right\n");
93
+ expect(new TextDecoder().decode((await reader.read()).value)).toContain("42");
94
+ await adapter.capture!(ctx);
95
+ const right = await readCoverageDir("live", ctx.reportDir);
96
+ expect(right.error).toBeUndefined();
97
+ if (process.env.SPECTEST_RUNTIME_COVERAGE_EVIDENCE) {
98
+ await fs.writeFile(path.join(process.env.SPECTEST_RUNTIME_COVERAGE_EVIDENCE, "bun/right.json.gz"), encodeCoverageBundle("right", [{ ...right, root }]));
99
+ }
100
+ (child.stdin as { end(): void }).end();
101
+ await child.exited;
102
+ } finally { child?.kill(); await fs.rm(root, { recursive: true, force: true }); }
103
+ }, 20000);
@@ -0,0 +1,40 @@
1
+ import { appendEnvFlag, node, NODE_COVERAGE_HOOK, NODE_COVERAGE_HOOK_PATH, type CoverageAdapter, type NodeCoverageOptions } from "./coverage.js";
2
+ import { access } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ /** Bun's native precise profiler, for `bun build --target=bun` bundles.
6
+ * Keep external or inline source maps with the bundle. Raw TS/JS requiring
7
+ * runtime transpilation is rejected because Bun cannot supply its compiled
8
+ * source through the in-process inspector. */
9
+ export interface BunCoverageOptions extends NodeCoverageOptions {
10
+ /** Only for idle command containers where no Bun process has run yet. */
11
+ allowEmpty?: boolean;
12
+ }
13
+
14
+ export function bun(options: BunCoverageOptions = {}): CoverageAdapter {
15
+ const adapter = node(options);
16
+ return {
17
+ ...adapter,
18
+ name: "bun",
19
+ configure(service, info) {
20
+ const configured = adapter.configure!(service, info);
21
+ const env = appendEnvFlag(service.env, "BUN_OPTIONS", `--preload ${NODE_COVERAGE_HOOK_PATH}`);
22
+ return { ...configured, env: {
23
+ ...configured.env, BUN_OPTIONS: env.BUN_OPTIONS,
24
+ // The Node adapter also contributes metadata for generated bundles.
25
+ NODE_OPTIONS: service.env?.NODE_OPTIONS ?? "",
26
+ SPECTEST_COVERAGE_RUNTIME: "bun",
27
+ }, files: [...(service.files ?? []), { path: NODE_COVERAGE_HOOK_PATH, content: NODE_COVERAGE_HOOK }] };
28
+ },
29
+ inventory: ctx => adapter.inventory!({ ...ctx, exec: command => ctx.exec(
30
+ command.replace(/\| node -/g, "| env BUN_OPTIONS= SPECTEST_COVERAGE_DIR= bun run -"),
31
+ ) }),
32
+ async capture(ctx) {
33
+ if (!options.allowEmpty) {
34
+ try { await access(path.join(ctx.reportDir, ".attached-bun")); }
35
+ catch { throw new Error("Bun coverage hook never attached; use a supported Bun runtime and preserve BUN_OPTIONS"); }
36
+ }
37
+ await adapter.capture!(ctx);
38
+ },
39
+ };
40
+ }