bynk_driver/test_runner.rs
1//! `bynkc test` / `bynk test`'s shared command body (Wave 5 §5.4, findings
2//! #40/#72/#20/#21 remainder): compile the project's test declarations, write
3//! them, and run them via `tsc → node` (falling back to `tsx`), folding the
4//! result into the pinned [`crate::test_json::TestRun`] document in
5//! `--format json` mode. Moved down from `bynkc` — both `bynkc` and `bynk`
6//! need one implementation instead of two.
7//!
8//! Runners are found with [`crate::probe::detect`] and
9//! [`crate::probe::DetectOpts::default()`] (no project-local search, no `npx`
10//! fallback), the one detection implementation both CLIs share for
11//! `doctor`/`dev`. #1758: each is spawned by the path detection resolved, not by
12//! its bare name, so a Windows npm shim (`tsc.cmd`) runs, and a runner that is
13//! found but won't start is reported as such rather than as missing.
14
15use std::path::{Path, PathBuf};
16use std::process::{Command as ProcCommand, ExitCode, Stdio};
17
18use bynk_emit::project::{BuildTarget, ImportExt, ProjectOutput};
19use clap::ValueEnum;
20
21use crate::probe::{DetectOpts, SystemToolbox};
22use crate::test_json::{Case, Location, Suite, TestRun};
23
24/// Where `name` is installed on `PATH`, if it is. #1758: callers spawn this
25/// path, not the bare name, so a Windows npm shim (`tsc.cmd`) that detection
26/// finds is also the program that runs.
27fn resolve_tool(name: &str) -> Option<PathBuf> {
28 crate::probe::detect(&SystemToolbox, name, DetectOpts::default())
29 .provenance
30 .path()
31 .map(Path::to_path_buf)
32}
33
34/// #1758: a runner that was found but would not start. Recorded instead of
35/// silently skipped, so a run with no working runner reports the real cause
36/// rather than advising an install of something already installed.
37fn start_failure(path: &Path, e: &std::io::Error) -> String {
38 format!(
39 "`{}` was found but could not be started: {e}",
40 path.display()
41 )
42}
43
44/// `test --format` selector, shared by `bynkc test` and `bynk test` (review
45/// findings #40/#72): one enum both CLIs' `Test` subcommand uses, instead of
46/// two structurally-identical copies that must be hand-kept in sync.
47#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, ValueEnum)]
48pub enum TestFormat {
49 /// The grouped `✓ / ✗` human output (the default; unchanged behaviour).
50 #[default]
51 Rich,
52 /// A single pinned JSON document of results, for tooling and CI.
53 Json,
54}
55
56impl TestFormat {
57 /// The `--format` token this maps to when `bynk test` shells a resolved
58 /// `bynkc` (`TestFormat::as_str` isn't `ValueEnum`-derived, since the
59 /// wire value and the token clap parses happen to coincide, but the two
60 /// concerns — "what did the user type" vs. "what do I forward" — are
61 /// worth keeping textually distinct call sites for).
62 pub fn as_bynkc_arg(self) -> &'static str {
63 match self {
64 TestFormat::Rich => "rich",
65 TestFormat::Json => "json",
66 }
67 }
68}
69
70/// The `test` subcommand's flags — the one contract `bynkc test` and `bynk
71/// test` both `#[command(flatten)]` (review findings #40/#72/#20/#21
72/// remainder), replacing four independent hand-spellings (`bynkc::cli`,
73/// `bynk::cli`, `bynk::test::TestArgs`, and the argv-literal rebuild that
74/// turned the latter back into flags for the `bynkc` shell-out) with one.
75/// Field docs here are the CLI help text for both commands' flags.
76#[derive(clap::Args, Debug)]
77pub struct TestArgs {
78 /// Input project root directory. Defaults to the current directory.
79 #[arg(default_value = ".")]
80 pub input: PathBuf,
81 /// Where to write compiled TypeScript test runner modules.
82 /// Defaults to `<input>/out`.
83 #[arg(short, long)]
84 pub output: Option<PathBuf>,
85 /// Skip the runner invocation. With `--format rich` this emits the
86 /// generated test files (for CI flows that drive the runner separately);
87 /// with `--format json` it emits a discovery document listing every
88 /// suite and case (each `outcome: "discovered"`) without running them —
89 /// a pure compile, no `tsc`/Node.
90 #[arg(long)]
91 pub no_run: bool,
92 /// Output format. `rich` (default) is the grouped ✓ / ✗ human output;
93 /// `json` is a single pinned JSON document of results, for tooling.
94 #[arg(long, value_enum, default_value_t = TestFormat::Rich)]
95 pub format: TestFormat,
96 /// Compile a debug build and launch the test runner under Node's
97 /// inspector (`node --inspect-brk`), printing the inspector URL for a
98 /// JavaScript debugger to attach. The emitted `.ts` runs directly under
99 /// Node's line-preserving type-stripping, so source maps resolve
100 /// breakpoints back to `.bynk`. Requires Node ≥ 22.18 (or ≥ 23.6
101 /// unflagged). Does not run `tsc`.
102 #[arg(long)]
103 pub inspect: bool,
104 /// The root seed for generative `property` tests, as hex (e.g. `0x5f3a`).
105 /// A failing property prints the seed it used; re-running with `--seed
106 /// <hex>` reproduces that run byte-for-byte. Omitted, each run draws a
107 /// fresh random seed.
108 #[arg(long)]
109 pub seed: Option<String>,
110 /// Run only test cases whose name matches `<name>`, skipping the rest —
111 /// the filter behind the editor's per-case `▷ Run Test` lens. Matches by
112 /// exact case name across suites; omitted, every case runs. No effect
113 /// with `--no-run` (discovery lists all cases regardless).
114 #[arg(long, value_name = "NAME")]
115 pub case: Option<String>,
116 /// After the suite runs, report statement/line coverage attributed to
117 /// `.bynk` source (a rich summary table, or a `coverage` block in
118 /// `--format json`). Requires the `tsc → node` path: incompatible with
119 /// `--inspect` and `--no-run`, and errors if only `tsx` is available.
120 #[arg(long)]
121 pub coverage: bool,
122}
123
124/// Normalise a `--seed` value (`0x5f3a` or `5f3a`) to the bare-hex form the
125/// runner reads from `BYNK_TEST_SEED` (JS `parseInt(_, 16)` does not accept a
126/// `0x` prefix). Returns `None` for a non-hex value, so a typo is ignored rather
127/// than silently seeding to zero.
128fn normalise_seed(raw: &str) -> Option<String> {
129 let hex = raw
130 .strip_prefix("0x")
131 .or_else(|| raw.strip_prefix("0X"))
132 .unwrap_or(raw);
133 if hex.is_empty() || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
134 return None;
135 }
136 Some(hex.to_string())
137}
138
139/// In `--format json` mode the deterministic surface is the document on stdout,
140/// so a `<program> test:` line on stderr is fine but must never reach stdout.
141/// `program` prefixes stderr messages (`"bynkc"` or `"bynk"`) so they read the
142/// same as before the move.
143pub fn run_test(program: &str, args: TestArgs) -> ExitCode {
144 let TestArgs {
145 input,
146 output,
147 no_run,
148 format,
149 inspect,
150 seed,
151 case,
152 coverage,
153 } = args;
154 let json = format == TestFormat::Json;
155 // #854 DECISION C: `--coverage` requires the `tsc → node` path — the CI-shaped
156 // path with real `.js.map`s. `--inspect` is a debug path with no `tsc` (and a
157 // different map role), and `--no-run` never launches a process to observe.
158 // Reject both up front with an actionable message rather than producing
159 // silently-wrong or empty numbers.
160 if coverage && inspect {
161 return coverage_unsupported(
162 program,
163 json,
164 "`--coverage` cannot be combined with `--inspect` — coverage needs the `tsc → node` run, not the inspector.",
165 );
166 }
167 if coverage && no_run {
168 return coverage_unsupported(
169 program,
170 json,
171 "`--coverage` cannot be combined with `--no-run` — there is no run to measure.",
172 );
173 }
174 // v0.127 (editor-currency slice 6): the per-case run filter. An empty
175 // `--case` is treated as unset (run all) rather than "match the empty name".
176 let case_filter = case.filter(|c| !c.is_empty());
177 // v0.114: the root seed for generative `property` tests, threaded to the
178 // runner via `BYNK_TEST_SEED` (bare hex). An unparseable value is dropped
179 // with a warning so a run still proceeds with a fresh seed.
180 let seed_hex = match seed.as_deref() {
181 Some(raw) => match normalise_seed(raw) {
182 Some(hex) => Some(hex),
183 None => {
184 if !json {
185 eprintln!(
186 "{program} test: ignoring --seed `{raw}` (not a hex value like 0x5f3a)"
187 );
188 }
189 None
190 }
191 },
192 None => None,
193 };
194 let output_root = output.unwrap_or_else(|| input.join("out"));
195 if !input.is_dir() {
196 eprintln!(
197 "{program} test: input `{}` must be a project directory containing `.bynk` files",
198 input.display()
199 );
200 return ExitCode::FAILURE;
201 }
202 // v0.9.1: rooting strategy (#46: shared with check/compile via
203 // `project_options`) — a `bynk.toml` or `src/` subdir selects split-paths
204 // mode (sources under `[paths] src`, tests under `[paths] tests`); else the
205 // legacy single-tree where `<input>` is both the source and tests root.
206 // `--inspect` compiles a debug build: `.ts` import specifiers so the emitted
207 // entry runs directly under Node's strip-only type-stripping (slice 2), where
208 // slice 1's source maps apply unchanged. A normal run keeps `.js` specifiers
209 // for the `tsc → node` path.
210 let options = {
211 // v0.115: `test` compiles the dev/test profile — the function
212 // contract call-site guard is emitted (DECISION J). `compile`
213 // leaves it off, so contract checks never reach production.
214 let o = match crate::try_project_options(&input) {
215 Ok(o) => o.contracts(true),
216 Err(e) => {
217 if json {
218 print!("{}", TestRun::runtime_error(e.to_string(), None).render());
219 } else {
220 eprintln!("{program} test: {e}");
221 }
222 return ExitCode::FAILURE;
223 }
224 };
225 if inspect {
226 o.import_ext(ImportExt::Ts)
227 } else {
228 o
229 }
230 };
231 let out = match bynk_emit::project::compile_project(&options) {
232 Ok(out) => out,
233 Err(failure) => {
234 if json {
235 print!(
236 "{}",
237 TestRun::compile_error(crate::project_failure_short_lines(&failure)).render()
238 );
239 } else {
240 crate::print_project_failure(&failure);
241 }
242 return ExitCode::FAILURE;
243 }
244 };
245 // v0.67: `--no-run --format json` is pure discovery — render the suite/case
246 // manifest the compile retained and stop. No TS is written, no `tsc`/`node`
247 // runs, and the integration workers re-compile below is skipped (the manifest
248 // already carries integration suites from the compile above). A compile
249 // failure took the `compile`-error path above, exactly as a run would.
250 if no_run && json {
251 print!("{}", TestRun::discovered(discovery_suites(&out)).render());
252 return ExitCode::SUCCESS;
253 }
254
255 // Write every artefact to disk under the output root.
256 let mut wrote_any_test = false;
257 let mut has_integration = false;
258 for (path, doc) in &out.artefacts.docs {
259 // Map-aware write (slice 2): carries the `.ts.map` siblings + trailers so
260 // a debug run (`--inspect`) can resolve `.bynk` breakpoints. Harmless for a
261 // normal run, which transpiles via `tsc` and ignores the trailer.
262 if let Err(e) = crate::write_document(path, doc, &out.artefacts.docs, &output_root) {
263 eprintln!(
264 "{program} test: could not write `{}`: {e}",
265 output_root.join(path).display()
266 );
267 return ExitCode::FAILURE;
268 }
269 let rel = path.to_string_lossy();
270 if rel.starts_with("tests/") {
271 wrote_any_test = true;
272 }
273 if rel.starts_with("tests/integration_") {
274 has_integration = true;
275 }
276 }
277
278 // v0.16: integration tests stand their participants up as real Workers, so
279 // they import the workers-mode output (`workers/**`) and the serialise/
280 // deserialise helpers the workers commons emit. The bundle compile above
281 // does not produce those, so run a second compile in workers mode and
282 // overlay everything except the `tests/` tree (whose unit modules import
283 // the bundle output). The workers commons are a strict superset of the
284 // bundle ones, so overwriting them is safe for the bundle code too.
285 if has_integration {
286 // v0.115/slice 2: reuse `options` (not a fresh `project_options(&input)`)
287 // so this second compile keeps `contracts(true)` and, under `--inspect`,
288 // `import_ext(Ts)` — a from-scratch rebuild silently dropped both.
289 let workers_out =
290 bynk_emit::project::compile_project(&options.clone().target(BuildTarget::Workers));
291 let workers_out = match workers_out {
292 Ok(o) => o,
293 Err(failure) => {
294 if json {
295 print!(
296 "{}",
297 TestRun::compile_error(crate::project_failure_short_lines(&failure))
298 .render()
299 );
300 } else {
301 crate::print_project_failure(&failure);
302 }
303 return ExitCode::FAILURE;
304 }
305 };
306 for (path, doc) in &workers_out.artefacts.docs {
307 if path.to_string_lossy().starts_with("tests/") {
308 continue;
309 }
310 if let Err(e) =
311 crate::write_document(path, doc, &workers_out.artefacts.docs, &output_root)
312 {
313 eprintln!(
314 "{program} test: could not write `{}`: {e}",
315 output_root.join(path).display()
316 );
317 return ExitCode::FAILURE;
318 }
319 }
320 }
321
322 if !wrote_any_test {
323 if json {
324 print!("{}", empty_run().render());
325 } else {
326 eprintln!(
327 "{program} test: no test declarations found in `{}`",
328 input.display()
329 );
330 }
331 return ExitCode::SUCCESS;
332 }
333
334 let main_ts = output_root.join("tests").join("main.ts");
335 if no_run {
336 // Rich `--no-run` is the CI emit helper: write the runner modules and
337 // report where they landed. (JSON `--no-run` already returned above with
338 // the discovery document — it never reaches here.)
339 eprintln!("{program} test: tests emitted to {}", main_ts.display());
340 return ExitCode::SUCCESS;
341 }
342
343 // Slice 2 (ADR 0104): launch the emitted `.ts` test entry directly under
344 // Node's inspector. No `tsc` — the `.ts` runs under line-preserving
345 // type-stripping, so the source maps written above resolve `.bynk`
346 // breakpoints. Node prints its inspector URL; a debugger attaches there.
347 if inspect {
348 return run_inspect(
349 program,
350 &main_ts,
351 seed_hex.as_deref(),
352 case_filter.as_deref(),
353 );
354 }
355
356 let tsconfig = output_root.join("tsconfig.json");
357 // #854: coverage needs tsc's `.js.map`s (remap hop 1). Overwrite the default
358 // tsconfig the compile wrote with the `sourceMap: true` variant, kept
359 // coverage-only so a normal test run / deployment build ships no maps.
360 if coverage
361 && let Err(e) = std::fs::write(
362 &tsconfig,
363 bynk_emit::emitter::emit_tsconfig_with_source_maps(),
364 )
365 {
366 return coverage_unsupported(
367 program,
368 json,
369 format!("could not enable source maps for coverage: {e}"),
370 );
371 }
372 // Preferred: `tsc -p out/tsconfig.json` → `node out-js/tests/main.js`.
373 // tsc gives us full type-checking before execution and matches what a
374 // production deployment build would do. If tsc is missing, fall back to
375 // tsx, which compiles-and-runs in one step. We also try npx-mediated
376 // variants so a developer with `npm` available doesn't need a global
377 // install. If nothing works, emit an actionable error message.
378 let out_js_root = output_root
379 .parent()
380 .map(|p| p.join("out-js"))
381 .unwrap_or_else(|| PathBuf::from("out-js"));
382 let main_js = out_js_root.join("tests").join("main.js");
383
384 // Try a sequence of (program, prefix args) tsc invocations. In JSON mode the
385 // tsc step is captured so its output never reaches stdout (the document is
386 // the only thing on stdout); a tsc failure on the emitted TS is a
387 // toolchain/internal problem, surfaced as a `runtime` error.
388 // #1672: the `npx` fallback provisions the TypeScript major the output is
389 // verified against and users get, not an older pin.
390 let typescript_pkg = format!("typescript@{}", bynk_emit::TYPESCRIPT_MAJOR_TESTED);
391 let mut start_failures: Vec<String> = Vec::new();
392 let tsc_runners: Vec<(&str, Vec<&str>)> = vec![
393 ("tsc", vec![]),
394 ("npx", vec!["--yes", "-p", typescript_pkg.as_str(), "tsc"]),
395 ];
396 for (prog, prefix) in &tsc_runners {
397 let Some(path) = resolve_tool(prog) else {
398 continue;
399 };
400 let mut cmd = ProcCommand::new(&path);
401 for p in prefix {
402 cmd.arg(p);
403 }
404 cmd.arg("-p").arg(&tsconfig);
405 let tsc_ok = if json {
406 match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output() {
407 Ok(out) if out.status.success() => true,
408 Ok(out) => {
409 // tsc writes its diagnostics to *stdout*; capturing only
410 // stderr left the document's most useful field empty.
411 let mut detail = String::from_utf8_lossy(&out.stdout).into_owned();
412 let err_text = String::from_utf8_lossy(&out.stderr);
413 if !err_text.trim().is_empty() {
414 if !detail.is_empty() {
415 detail.push('\n');
416 }
417 detail.push_str(&err_text);
418 }
419 print!(
420 "{}",
421 TestRun::runtime_error(
422 "tsc rejected the generated TypeScript",
423 Some(detail),
424 )
425 .render()
426 );
427 return ExitCode::FAILURE;
428 }
429 Err(e) => {
430 start_failures.push(start_failure(&path, &e));
431 continue;
432 }
433 }
434 } else {
435 match cmd
436 .stdout(Stdio::inherit())
437 .stderr(Stdio::inherit())
438 .status()
439 {
440 Ok(s) if s.success() => true,
441 Ok(_) => {
442 eprintln!(
443 "{program} test: tsc reported errors against {}",
444 tsconfig.display()
445 );
446 return ExitCode::FAILURE;
447 }
448 Err(e) => {
449 start_failures.push(start_failure(&path, &e));
450 continue;
451 }
452 }
453 };
454 if tsc_ok {
455 let mut node_cmd = ProcCommand::new(crate::probe::program_path("node"));
456 node_cmd.arg(&main_js);
457 // #854: the coverage path owns the node launch (it sets
458 // `NODE_V8_COVERAGE` and reads the result back), so it does not go
459 // through `finish_runner`.
460 if coverage {
461 return run_with_coverage(
462 program,
463 node_cmd,
464 json,
465 seed_hex.as_deref(),
466 case_filter.as_deref(),
467 &out_js_root,
468 &output_root,
469 &input,
470 );
471 }
472 return match finish_runner(node_cmd, json, seed_hex.as_deref(), case_filter.as_deref())
473 {
474 Ok(code) => code,
475 Err(e) => {
476 if json {
477 print!(
478 "{}",
479 TestRun::runtime_error(format!("could not run node: {e}"), None)
480 .render()
481 );
482 } else {
483 eprintln!(
484 "{program} test: tsc succeeded but `node {}` failed: {e}",
485 main_js.display()
486 );
487 }
488 ExitCode::FAILURE
489 }
490 };
491 }
492 }
493
494 // #854 DECISION C: with `--coverage`, the `tsc → node` path is required — do
495 // not silently fall through to `tsx`, whose on-the-fly transform muddies
496 // which map applies. Fail clearly instead.
497 if coverage {
498 // #1761 review: a `tsc` that is installed but won't start is reported as
499 // such here too, not as missing.
500 if !start_failures.is_empty() {
501 return report_start_failures(program, json, &start_failures, true);
502 }
503 return coverage_unsupported(
504 program,
505 json,
506 "`--coverage` requires `tsc` and `node` on PATH (the CI-shaped path with `.js.map`s); the `tsx` fallback is not supported for coverage.",
507 );
508 }
509
510 // tsx fallback chain.
511 let tsx_runners: Vec<(&str, Vec<&str>)> = vec![("tsx", vec![]), ("npx", vec!["--yes", "tsx"])];
512 for (prog, prefix) in &tsx_runners {
513 let Some(path) = resolve_tool(prog) else {
514 continue;
515 };
516 let mut cmd = ProcCommand::new(&path);
517 for p in prefix {
518 cmd.arg(p);
519 }
520 cmd.arg(&main_ts);
521 match finish_runner(cmd, json, seed_hex.as_deref(), case_filter.as_deref()) {
522 Ok(code) => return code,
523 Err(e) => start_failures.push(start_failure(&path, &e)),
524 }
525 }
526
527 if !start_failures.is_empty() {
528 return report_start_failures(program, json, &start_failures, false);
529 }
530
531 if json {
532 print!(
533 "{}",
534 TestRun::runtime_error(
535 "no test runner found: requires `tsc` (with Node.js) or `tsx` on PATH",
536 None
537 )
538 .render()
539 );
540 } else {
541 eprintln!(
542 "{program} test: requires either `tsc` (with Node.js) or `tsx` on PATH. \
543 Install one of:\n{}",
544 install_advice(false)
545 );
546 }
547 ExitCode::FAILURE
548}
549
550/// #1758: the run found runners but none would start. Each failure names the
551/// resolved path and the error. In rich mode the install advice follows, since
552/// installing another runner is often the fix for a broken one. `coverage`
553/// limits that advice to `tsc`, the only runner `--coverage` accepts.
554fn report_start_failures(
555 program: &str,
556 json: bool,
557 failures: &[String],
558 coverage: bool,
559) -> ExitCode {
560 if json {
561 print!(
562 "{}",
563 TestRun::runtime_error("no test runner could be started", Some(failures.join("\n")))
564 .render()
565 );
566 } else {
567 eprintln!("{program} test: no test runner could be started:");
568 for failure in failures {
569 eprintln!(" - {failure}");
570 }
571 eprintln!("Or install another:\n{}", install_advice(coverage));
572 }
573 ExitCode::FAILURE
574}
575
576/// The install advice for a missing or broken runner, one indented line each.
577/// With `coverage`, only `tsc`, since `--coverage` doesn't accept `tsx`.
578fn install_advice(coverage: bool) -> String {
579 let tsc = format!(
580 " - `npm install -g typescript@{}` (provides tsc, which type-checks; requires Node.js to run output)",
581 bynk_emit::TYPESCRIPT_MAJOR_TESTED
582 );
583 if coverage {
584 return tsc;
585 }
586 format!(
587 "{tsc}\n - `npm install -g tsx` (runs TypeScript in one step, without type-checking)\n Or run inside a project where `npx tsc` / `npx tsx` resolves."
588 )
589}
590
591/// A normal run with no suites — the JSON-mode document for a project with no
592/// tests, or `--no-run`.
593fn empty_run() -> TestRun {
594 TestRun::empty()
595}
596
597/// v0.67: map the compile's retained test manifest into discovery [`Suite`]s for
598/// the `--no-run --format json` document. Each case is `outcome: "discovered"`,
599/// carrying its declaration `location` (when known) for editor click-through.
600fn discovery_suites(out: &ProjectOutput) -> Vec<Suite> {
601 out.discovered
602 .iter()
603 .map(|s| Suite {
604 name: s.name.clone(),
605 kind: s.kind.to_string(),
606 cases: s
607 .cases
608 .iter()
609 .map(|c| Case {
610 name: c.name.clone(),
611 outcome: "discovered".to_string(),
612 message: None,
613 location: c.location.as_ref().map(|l| Location {
614 path: l.path.clone(),
615 line: l.line,
616 col: l.col,
617 }),
618 })
619 .collect(),
620 })
621 .collect()
622}
623
624/// Execute the built runner command and produce its exit code. In JSON mode the
625/// runner's stdout (NDJSON) and stderr are captured, folded into the pinned
626/// document, and printed; otherwise stdio is inherited so the human ✓ / ✗ output
627/// flows straight through. Either way the **exit code follows the runner's own
628/// process status**, so a mid-run crash (a complete NDJSON prefix but no
629/// `run-end`) is never reported as success.
630fn finish_runner(
631 mut cmd: ProcCommand,
632 json: bool,
633 seed_hex: Option<&str>,
634 case: Option<&str>,
635) -> std::io::Result<ExitCode> {
636 if let Some(hex) = seed_hex {
637 cmd.env("BYNK_TEST_SEED", hex);
638 }
639 if let Some(name) = case {
640 cmd.env("BYNK_TEST_CASE", name);
641 }
642 if json {
643 cmd.env("BYNK_TEST_FORMAT", "ndjson");
644 let out = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output()?;
645 let stdout = String::from_utf8_lossy(&out.stdout);
646 let stderr = String::from_utf8_lossy(&out.stderr);
647 let doc = crate::test_json::parse_ndjson(&stdout).into_document(&stderr);
648 print!("{}", doc.render());
649 Ok(exit_from(out.status.success()))
650 } else {
651 let status = cmd
652 .stdout(Stdio::inherit())
653 .stderr(Stdio::inherit())
654 .status()?;
655 Ok(exit_from(status.success()))
656 }
657}
658
659fn exit_from(success: bool) -> ExitCode {
660 if success {
661 ExitCode::SUCCESS
662 } else {
663 ExitCode::FAILURE
664 }
665}
666
667/// #854: a `--coverage` request that cannot be honoured (unsupported flag combo,
668/// no `tsc → node`, or a setup error). In JSON mode it is a `runtime` error so
669/// the document stays the only thing on stdout; in rich mode a plain stderr line.
670fn coverage_unsupported(program: &str, json: bool, message: impl Into<String>) -> ExitCode {
671 let message = message.into();
672 if json {
673 print!("{}", TestRun::runtime_error(message, None).render());
674 } else {
675 eprintln!("{program} test --coverage: {message}");
676 }
677 ExitCode::FAILURE
678}
679
680/// #854: run the emitted runner under V8 coverage and attribute the result to
681/// `.bynk` source. Owns the `node` launch: it points `NODE_V8_COVERAGE` at a
682/// scratch dir, runs the suite exactly as [`finish_runner`] would, then remaps
683/// the V8 output through the emitted source maps ([`crate::coverage`]). In rich
684/// mode the summary table is appended after the human ✓ / ✗ output; in JSON mode
685/// the `coverage` block is folded into the pinned document. The **exit code
686/// follows the run's own status** — coverage is a report about the run, never a
687/// gate on it (a partial or unreadable map degrades the numbers, not the code).
688#[allow(clippy::too_many_arguments)]
689fn run_with_coverage(
690 program: &str,
691 mut cmd: ProcCommand,
692 json: bool,
693 seed_hex: Option<&str>,
694 case: Option<&str>,
695 out_js_root: &Path,
696 out_root: &Path,
697 source_root: &Path,
698) -> ExitCode {
699 // A scratch dir beside the build output; cleared first so a prior run's JSON
700 // never leaks in. `NODE_V8_COVERAGE` writes one file per process on exit.
701 let cov_dir = out_root.join(".v8-coverage");
702 let _ = std::fs::remove_dir_all(&cov_dir);
703 if let Err(e) = std::fs::create_dir_all(&cov_dir) {
704 return coverage_unsupported(
705 program,
706 json,
707 format!("could not create the coverage dir: {e}"),
708 );
709 }
710 cmd.env("NODE_V8_COVERAGE", &cov_dir);
711 if let Some(hex) = seed_hex {
712 cmd.env("BYNK_TEST_SEED", hex);
713 }
714 if let Some(name) = case {
715 cmd.env("BYNK_TEST_CASE", name);
716 }
717
718 let collect = || {
719 crate::coverage::collect_coverage(&cov_dir, out_js_root, out_root, source_root)
720 .unwrap_or_default()
721 };
722
723 let code = if json {
724 cmd.env("BYNK_TEST_FORMAT", "ndjson");
725 let out = match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output() {
726 Ok(o) => o,
727 Err(e) => {
728 let _ = std::fs::remove_dir_all(&cov_dir);
729 return coverage_unsupported(program, true, format!("could not run node: {e}"));
730 }
731 };
732 let stdout = String::from_utf8_lossy(&out.stdout);
733 let stderr = String::from_utf8_lossy(&out.stderr);
734 let report = collect();
735 let doc = crate::test_json::parse_ndjson(&stdout)
736 .into_document(&stderr)
737 .with_coverage(&report);
738 print!("{}", doc.render());
739 exit_from(out.status.success())
740 } else {
741 let status = cmd
742 .stdout(Stdio::inherit())
743 .stderr(Stdio::inherit())
744 .status();
745 let status = match status {
746 Ok(s) => s,
747 Err(e) => {
748 let _ = std::fs::remove_dir_all(&cov_dir);
749 eprintln!("{program} test --coverage: could not run node: {e}");
750 return ExitCode::FAILURE;
751 }
752 };
753 let report = collect();
754 print!("{}", crate::coverage::render_rich(&report));
755 exit_from(status.success())
756 };
757 let _ = std::fs::remove_dir_all(&cov_dir);
758 code
759}
760
761/// Slice 2 (ADR 0104): launch the emitted `.ts` test entry under Node's inspector
762/// and hand off. Node prints its inspector `ws://` URL to stderr and pauses at the
763/// first line (`--inspect-brk`) until a JavaScript debugger attaches; breakpoints
764/// set in `.bynk` resolve through the emitted source maps. `--experimental-strip-types`
765/// runs the `.ts` directly under line-preserving type-stripping (Node ≥ 22.6;
766/// unflagged ≥ 23.6) — no `tsc`, so slice 1's `.ts.map` applies to the running file.
767fn run_inspect(
768 program: &str,
769 entry: &Path,
770 seed_hex: Option<&str>,
771 case: Option<&str>,
772) -> ExitCode {
773 let Some(node) = resolve_tool("node") else {
774 eprintln!("{program} test --inspect: `node` was not found on PATH");
775 return ExitCode::FAILURE;
776 };
777 eprintln!("{program} test --inspect: launching the test runner under Node's inspector.");
778 eprintln!(" Attach a JavaScript debugger to the inspector URL below; breakpoints set");
779 eprintln!(" in `.bynk` sources resolve through the emitted source maps.");
780 eprintln!(" (Requires Node \u{2265} 22.6 for TypeScript type-stripping.)");
781 let mut cmd = ProcCommand::new(node);
782 if let Some(hex) = seed_hex {
783 cmd.env("BYNK_TEST_SEED", hex);
784 }
785 if let Some(name) = case {
786 cmd.env("BYNK_TEST_CASE", name);
787 }
788 cmd.arg("--experimental-strip-types")
789 .arg("--inspect-brk")
790 .arg(entry);
791 match cmd
792 .stdout(Stdio::inherit())
793 .stderr(Stdio::inherit())
794 .status()
795 {
796 Ok(s) => exit_from(s.success()),
797 Err(e) => {
798 eprintln!("{program} test --inspect: could not run node: {e}");
799 ExitCode::FAILURE
800 }
801 }
802}