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 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}