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