Skip to main content

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}