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