@specific.dev/spectest 0.127.0 → 0.129.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/README.md +54 -0
- package/dist/browser.d.ts +12 -5
- package/dist/browser.js +13 -13
- package/dist/components/k3s-coverage.d.ts +24 -0
- package/dist/components/k3s-coverage.js +259 -0
- package/dist/components/k3s-trust.d.ts +4 -2
- package/dist/components/k3s-trust.js +13 -0
- package/dist/components/k3s.js +31 -0
- package/dist/coverage/rust.rs +102 -0
- package/dist/coverage-bun.d.ts +10 -0
- package/dist/coverage-bun.js +32 -0
- package/dist/coverage-rust.d.ts +23 -0
- package/dist/coverage-rust.js +71 -0
- package/dist/coverage.d.ts +11 -5
- package/dist/coverage.js +70 -13
- package/dist/daemon.js +27 -2
- package/dist/harness/component-images.d.ts +1 -0
- package/dist/harness/component-images.js +4 -2
- package/dist/harness/coverage-target.d.ts +10 -0
- package/dist/harness/coverage-target.js +54 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +10 -1
- package/dist/recorder.d.ts +29 -2
- package/dist/terminal.d.ts +31 -17
- package/dist/terminal.js +261 -105
- package/package.json +1 -1
- package/src/browser-navigation.test.ts +36 -0
- package/src/browser.ts +26 -18
- 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 +12 -3
- package/src/recorder.ts +22 -2
- package/src/terminal.test.ts +324 -0
- package/src/terminal.ts +269 -119
|
@@ -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
|
+
}
|
|
@@ -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(() => {});
|