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 artefact to disk under the output root.
238 let mut wrote_any_test = false;
239 let mut has_integration = false;
240 for (path, doc) in &out.artefacts.docs {
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_document(path, doc, &out.artefacts.docs, &output_root) {
245 eprintln!(
246 "{program} test: could not write `{}`: {e}",
247 output_root.join(path).display()
248 );
249 return ExitCode::FAILURE;
250 }
251 let rel = 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 (path, doc) in &workers_out.artefacts.docs {
289 if path.to_string_lossy().starts_with("tests/") {
290 continue;
291 }
292 if let Err(e) =
293 crate::write_document(path, doc, &workers_out.artefacts.docs, &output_root)
294 {
295 eprintln!(
296 "{program} test: could not write `{}`: {e}",
297 output_root.join(path).display()
298 );
299 return ExitCode::FAILURE;
300 }
301 }
302 }
303
304 if !wrote_any_test {
305 if json {
306 print!("{}", empty_run().render());
307 } else {
308 eprintln!(
309 "{program} test: no test declarations found in `{}`",
310 input.display()
311 );
312 }
313 return ExitCode::SUCCESS;
314 }
315
316 let main_ts = output_root.join("tests").join("main.ts");
317 if no_run {
318 // Rich `--no-run` is the CI emit helper: write the runner modules and
319 // report where they landed. (JSON `--no-run` already returned above with
320 // the discovery document — it never reaches here.)
321 eprintln!("{program} test: tests emitted to {}", main_ts.display());
322 return ExitCode::SUCCESS;
323 }
324
325 // Slice 2 (ADR 0104): launch the emitted `.ts` test entry directly under
326 // Node's inspector. No `tsc` — the `.ts` runs under line-preserving
327 // type-stripping, so the source maps written above resolve `.bynk`
328 // breakpoints. Node prints its inspector URL; a debugger attaches there.
329 if inspect {
330 return run_inspect(
331 program,
332 &main_ts,
333 seed_hex.as_deref(),
334 case_filter.as_deref(),
335 );
336 }
337
338 let tsconfig = output_root.join("tsconfig.json");
339 // #854: coverage needs tsc's `.js.map`s (remap hop 1). Overwrite the default
340 // tsconfig the compile wrote with the `sourceMap: true` variant, kept
341 // coverage-only so a normal test run / deployment build ships no maps.
342 if coverage
343 && let Err(e) = std::fs::write(
344 &tsconfig,
345 bynk_emit::emitter::emit_tsconfig_with_source_maps(),
346 )
347 {
348 return coverage_unsupported(
349 program,
350 json,
351 format!("could not enable source maps for coverage: {e}"),
352 );
353 }
354 // Preferred: `tsc -p out/tsconfig.json` → `node out-js/tests/main.js`.
355 // tsc gives us full type-checking before execution and matches what a
356 // production deployment build would do. If tsc is missing, fall back to
357 // tsx, which compiles-and-runs in one step. We also try npx-mediated
358 // variants so a developer with `npm` available doesn't need a global
359 // install. If nothing works, emit an actionable error message.
360 let out_js_root = output_root
361 .parent()
362 .map(|p| p.join("out-js"))
363 .unwrap_or_else(|| PathBuf::from("out-js"));
364 let main_js = out_js_root.join("tests").join("main.js");
365
366 // Try a sequence of (program, prefix args) tsc invocations. In JSON mode the
367 // tsc step is captured so its output never reaches stdout (the document is
368 // the only thing on stdout); a tsc failure on the emitted TS is a
369 // toolchain/internal problem, surfaced as a `runtime` error.
370 let tsc_runners: Vec<(&str, Vec<&str>)> = vec![
371 ("tsc", vec![]),
372 ("npx", vec!["--yes", "-p", "typescript@5", "tsc"]),
373 ];
374 for (prog, prefix) in &tsc_runners {
375 if !tool_exists(prog) {
376 continue;
377 }
378 let mut cmd = ProcCommand::new(prog);
379 for p in prefix {
380 cmd.arg(p);
381 }
382 cmd.arg("-p").arg(&tsconfig);
383 let tsc_ok = if json {
384 match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output() {
385 Ok(out) if out.status.success() => true,
386 Ok(out) => {
387 // tsc writes its diagnostics to *stdout*; capturing only
388 // stderr left the document's most useful field empty.
389 let mut detail = String::from_utf8_lossy(&out.stdout).into_owned();
390 let err_text = String::from_utf8_lossy(&out.stderr);
391 if !err_text.trim().is_empty() {
392 if !detail.is_empty() {
393 detail.push('\n');
394 }
395 detail.push_str(&err_text);
396 }
397 print!(
398 "{}",
399 TestRun::runtime_error(
400 "tsc rejected the generated TypeScript",
401 Some(detail),
402 )
403 .render()
404 );
405 return ExitCode::FAILURE;
406 }
407 Err(_) => continue,
408 }
409 } else {
410 match cmd
411 .stdout(Stdio::inherit())
412 .stderr(Stdio::inherit())
413 .status()
414 {
415 Ok(s) if s.success() => true,
416 Ok(_) => {
417 eprintln!(
418 "{program} test: tsc reported errors against {}",
419 tsconfig.display()
420 );
421 return ExitCode::FAILURE;
422 }
423 Err(_) => continue,
424 }
425 };
426 if tsc_ok {
427 let mut node_cmd = ProcCommand::new("node");
428 node_cmd.arg(&main_js);
429 // #854: the coverage path owns the node launch (it sets
430 // `NODE_V8_COVERAGE` and reads the result back), so it does not go
431 // through `finish_runner`.
432 if coverage {
433 return run_with_coverage(
434 program,
435 node_cmd,
436 json,
437 seed_hex.as_deref(),
438 case_filter.as_deref(),
439 &out_js_root,
440 &output_root,
441 &input,
442 );
443 }
444 return match finish_runner(node_cmd, json, seed_hex.as_deref(), case_filter.as_deref())
445 {
446 Ok(code) => code,
447 Err(e) => {
448 if json {
449 print!(
450 "{}",
451 TestRun::runtime_error(format!("could not run node: {e}"), None)
452 .render()
453 );
454 } else {
455 eprintln!(
456 "{program} test: tsc succeeded but `node {}` failed: {e}",
457 main_js.display()
458 );
459 }
460 ExitCode::FAILURE
461 }
462 };
463 }
464 }
465
466 // #854 DECISION C: with `--coverage`, the `tsc → node` path is required — do
467 // not silently fall through to `tsx`, whose on-the-fly transform muddies
468 // which map applies. Fail clearly instead.
469 if coverage {
470 return coverage_unsupported(
471 program,
472 json,
473 "`--coverage` requires `tsc` and `node` on PATH (the CI-shaped path with `.js.map`s); the `tsx` fallback is not supported for coverage.",
474 );
475 }
476
477 // tsx fallback chain.
478 let tsx_runners: Vec<(&str, Vec<&str>)> = vec![("tsx", vec![]), ("npx", vec!["--yes", "tsx"])];
479 for (prog, prefix) in &tsx_runners {
480 if !tool_exists(prog) {
481 continue;
482 }
483 let mut cmd = ProcCommand::new(prog);
484 for p in prefix {
485 cmd.arg(p);
486 }
487 cmd.arg(&main_ts);
488 match finish_runner(cmd, json, seed_hex.as_deref(), case_filter.as_deref()) {
489 Ok(code) => return code,
490 Err(_) => continue,
491 }
492 }
493
494 if json {
495 print!(
496 "{}",
497 TestRun::runtime_error(
498 "no test runner found: requires `tsc` (with Node.js) or `tsx` on PATH",
499 None
500 )
501 .render()
502 );
503 } else {
504 eprintln!(
505 "{program} test: requires either `tsc` (with Node.js) or `tsx` on PATH. \
506 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.",
507 );
508 }
509 ExitCode::FAILURE
510}
511
512/// A normal run with no suites — the JSON-mode document for a project with no
513/// tests, or `--no-run`.
514fn empty_run() -> TestRun {
515 TestRun::empty()
516}
517
518/// v0.67: map the compile's retained test manifest into discovery [`Suite`]s for
519/// the `--no-run --format json` document. Each case is `outcome: "discovered"`,
520/// carrying its declaration `location` (when known) for editor click-through.
521fn discovery_suites(out: &ProjectOutput) -> Vec<Suite> {
522 out.discovered
523 .iter()
524 .map(|s| Suite {
525 name: s.name.clone(),
526 kind: s.kind.to_string(),
527 cases: s
528 .cases
529 .iter()
530 .map(|c| Case {
531 name: c.name.clone(),
532 outcome: "discovered".to_string(),
533 message: None,
534 location: c.location.as_ref().map(|l| Location {
535 path: l.path.clone(),
536 line: l.line,
537 col: l.col,
538 }),
539 })
540 .collect(),
541 })
542 .collect()
543}
544
545/// Execute the built runner command and produce its exit code. In JSON mode the
546/// runner's stdout (NDJSON) and stderr are captured, folded into the pinned
547/// document, and printed; otherwise stdio is inherited so the human ✓ / ✗ output
548/// flows straight through. Either way the **exit code follows the runner's own
549/// process status**, so a mid-run crash (a complete NDJSON prefix but no
550/// `run-end`) is never reported as success.
551fn finish_runner(
552 mut cmd: ProcCommand,
553 json: bool,
554 seed_hex: Option<&str>,
555 case: Option<&str>,
556) -> std::io::Result<ExitCode> {
557 if let Some(hex) = seed_hex {
558 cmd.env("BYNK_TEST_SEED", hex);
559 }
560 if let Some(name) = case {
561 cmd.env("BYNK_TEST_CASE", name);
562 }
563 if json {
564 cmd.env("BYNK_TEST_FORMAT", "ndjson");
565 let out = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output()?;
566 let stdout = String::from_utf8_lossy(&out.stdout);
567 let stderr = String::from_utf8_lossy(&out.stderr);
568 let doc = crate::test_json::parse_ndjson(&stdout).into_document(&stderr);
569 print!("{}", doc.render());
570 Ok(exit_from(out.status.success()))
571 } else {
572 let status = cmd
573 .stdout(Stdio::inherit())
574 .stderr(Stdio::inherit())
575 .status()?;
576 Ok(exit_from(status.success()))
577 }
578}
579
580fn exit_from(success: bool) -> ExitCode {
581 if success {
582 ExitCode::SUCCESS
583 } else {
584 ExitCode::FAILURE
585 }
586}
587
588/// #854: a `--coverage` request that cannot be honoured (unsupported flag combo,
589/// no `tsc → node`, or a setup error). In JSON mode it is a `runtime` error so
590/// the document stays the only thing on stdout; in rich mode a plain stderr line.
591fn coverage_unsupported(program: &str, json: bool, message: impl Into<String>) -> ExitCode {
592 let message = message.into();
593 if json {
594 print!("{}", TestRun::runtime_error(message, None).render());
595 } else {
596 eprintln!("{program} test --coverage: {message}");
597 }
598 ExitCode::FAILURE
599}
600
601/// #854: run the emitted runner under V8 coverage and attribute the result to
602/// `.bynk` source. Owns the `node` launch: it points `NODE_V8_COVERAGE` at a
603/// scratch dir, runs the suite exactly as [`finish_runner`] would, then remaps
604/// the V8 output through the emitted source maps ([`crate::coverage`]). In rich
605/// mode the summary table is appended after the human ✓ / ✗ output; in JSON mode
606/// the `coverage` block is folded into the pinned document. The **exit code
607/// follows the run's own status** — coverage is a report about the run, never a
608/// gate on it (a partial or unreadable map degrades the numbers, not the code).
609#[allow(clippy::too_many_arguments)]
610fn run_with_coverage(
611 program: &str,
612 mut cmd: ProcCommand,
613 json: bool,
614 seed_hex: Option<&str>,
615 case: Option<&str>,
616 out_js_root: &Path,
617 out_root: &Path,
618 source_root: &Path,
619) -> ExitCode {
620 // A scratch dir beside the build output; cleared first so a prior run's JSON
621 // never leaks in. `NODE_V8_COVERAGE` writes one file per process on exit.
622 let cov_dir = out_root.join(".v8-coverage");
623 let _ = std::fs::remove_dir_all(&cov_dir);
624 if let Err(e) = std::fs::create_dir_all(&cov_dir) {
625 return coverage_unsupported(
626 program,
627 json,
628 format!("could not create the coverage dir: {e}"),
629 );
630 }
631 cmd.env("NODE_V8_COVERAGE", &cov_dir);
632 if let Some(hex) = seed_hex {
633 cmd.env("BYNK_TEST_SEED", hex);
634 }
635 if let Some(name) = case {
636 cmd.env("BYNK_TEST_CASE", name);
637 }
638
639 let collect = || {
640 crate::coverage::collect_coverage(&cov_dir, out_js_root, out_root, source_root)
641 .unwrap_or_default()
642 };
643
644 let code = if json {
645 cmd.env("BYNK_TEST_FORMAT", "ndjson");
646 let out = match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output() {
647 Ok(o) => o,
648 Err(e) => {
649 let _ = std::fs::remove_dir_all(&cov_dir);
650 return coverage_unsupported(program, true, format!("could not run node: {e}"));
651 }
652 };
653 let stdout = String::from_utf8_lossy(&out.stdout);
654 let stderr = String::from_utf8_lossy(&out.stderr);
655 let report = collect();
656 let doc = crate::test_json::parse_ndjson(&stdout)
657 .into_document(&stderr)
658 .with_coverage(&report);
659 print!("{}", doc.render());
660 exit_from(out.status.success())
661 } else {
662 let status = cmd
663 .stdout(Stdio::inherit())
664 .stderr(Stdio::inherit())
665 .status();
666 let status = match status {
667 Ok(s) => s,
668 Err(e) => {
669 let _ = std::fs::remove_dir_all(&cov_dir);
670 eprintln!("{program} test --coverage: could not run node: {e}");
671 return ExitCode::FAILURE;
672 }
673 };
674 let report = collect();
675 print!("{}", crate::coverage::render_rich(&report));
676 exit_from(status.success())
677 };
678 let _ = std::fs::remove_dir_all(&cov_dir);
679 code
680}
681
682/// Slice 2 (ADR 0104): launch the emitted `.ts` test entry under Node's inspector
683/// and hand off. Node prints its inspector `ws://` URL to stderr and pauses at the
684/// first line (`--inspect-brk`) until a JavaScript debugger attaches; breakpoints
685/// set in `.bynk` resolve through the emitted source maps. `--experimental-strip-types`
686/// runs the `.ts` directly under line-preserving type-stripping (Node ≥ 22.6;
687/// unflagged ≥ 23.6) — no `tsc`, so slice 1's `.ts.map` applies to the running file.
688fn run_inspect(
689 program: &str,
690 entry: &Path,
691 seed_hex: Option<&str>,
692 case: Option<&str>,
693) -> ExitCode {
694 if !tool_exists("node") {
695 eprintln!("{program} test --inspect: `node` was not found on PATH");
696 return ExitCode::FAILURE;
697 }
698 eprintln!("{program} test --inspect: launching the test runner under Node's inspector.");
699 eprintln!(" Attach a JavaScript debugger to the inspector URL below; breakpoints set");
700 eprintln!(" in `.bynk` sources resolve through the emitted source maps.");
701 eprintln!(" (Requires Node \u{2265} 22.6 for TypeScript type-stripping.)");
702 let mut cmd = ProcCommand::new("node");
703 if let Some(hex) = seed_hex {
704 cmd.env("BYNK_TEST_SEED", hex);
705 }
706 if let Some(name) = case {
707 cmd.env("BYNK_TEST_CASE", name);
708 }
709 cmd.arg("--experimental-strip-types")
710 .arg("--inspect-brk")
711 .arg(entry);
712 match cmd
713 .stdout(Stdio::inherit())
714 .stderr(Stdio::inherit())
715 .status()
716 {
717 Ok(s) => exit_from(s.success()),
718 Err(e) => {
719 eprintln!("{program} test --inspect: could not run node: {e}");
720 ExitCode::FAILURE
721 }
722 }
723}