bynk-driver 0.239.0

Shared front-end command bodies for the bynkc and bynk CLIs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
//! `bynkc test` / `bynk test`'s shared command body (Wave 5 §5.4, findings
//! #40/#72/#20/#21 remainder): compile the project's test declarations, write
//! them, and run them via `tsc → node` (falling back to `tsx`), folding the
//! result into the pinned [`crate::test_json::TestRun`] document in
//! `--format json` mode. Moved down from `bynkc` — both `bynkc` and `bynk`
//! need one implementation instead of two.
//!
//! `tool_exists` (a bare PATH check) is replaced by [`crate::probe::detect`]
//! with [`crate::probe::DetectOpts::default()`] (no project-local search, no
//! `npx` fallback) — behaviourally identical to the old check, routed through
//! the one detection implementation both CLIs already share for `doctor`/`dev`.

use std::path::{Path, PathBuf};
use std::process::{Command as ProcCommand, ExitCode, Stdio};

use bynk_emit::project::{BuildTarget, ImportExt, ProjectOutput};
use clap::ValueEnum;

use crate::probe::{DetectOpts, SystemToolbox};
use crate::test_json::{Case, Location, Suite, TestRun};

fn tool_exists(name: &str) -> bool {
    crate::probe::detect(&SystemToolbox, name, DetectOpts::default()).is_present()
}

/// `test --format` selector, shared by `bynkc test` and `bynk test` (review
/// findings #40/#72): one enum both CLIs' `Test` subcommand uses, instead of
/// two structurally-identical copies that must be hand-kept in sync.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, ValueEnum)]
pub enum TestFormat {
    /// The grouped `✓ / ✗` human output (the default; unchanged behaviour).
    #[default]
    Rich,
    /// A single pinned JSON document of results, for tooling and CI.
    Json,
}

impl TestFormat {
    /// The `--format` token this maps to when `bynk test` shells a resolved
    /// `bynkc` (`TestFormat::as_str` isn't `ValueEnum`-derived, since the
    /// wire value and the token clap parses happen to coincide, but the two
    /// concerns — "what did the user type" vs. "what do I forward" — are
    /// worth keeping textually distinct call sites for).
    pub fn as_bynkc_arg(self) -> &'static str {
        match self {
            TestFormat::Rich => "rich",
            TestFormat::Json => "json",
        }
    }
}

/// The `test` subcommand's flags — the one contract `bynkc test` and `bynk
/// test` both `#[command(flatten)]` (review findings #40/#72/#20/#21
/// remainder), replacing four independent hand-spellings (`bynkc::cli`,
/// `bynk::cli`, `bynk::test::TestArgs`, and the argv-literal rebuild that
/// turned the latter back into flags for the `bynkc` shell-out) with one.
/// Field docs here are the CLI help text for both commands' flags.
#[derive(clap::Args, Debug)]
pub struct TestArgs {
    /// Input project root directory. Defaults to the current directory.
    #[arg(default_value = ".")]
    pub input: PathBuf,
    /// Where to write compiled TypeScript test runner modules.
    /// Defaults to `<input>/out`.
    #[arg(short, long)]
    pub output: Option<PathBuf>,
    /// Skip the runner invocation. With `--format rich` this emits the
    /// generated test files (for CI flows that drive the runner separately);
    /// with `--format json` it emits a discovery document listing every
    /// suite and case (each `outcome: "discovered"`) without running them —
    /// a pure compile, no `tsc`/Node.
    #[arg(long)]
    pub no_run: bool,
    /// Output format. `rich` (default) is the grouped ✓ / ✗ human output;
    /// `json` is a single pinned JSON document of results, for tooling.
    #[arg(long, value_enum, default_value_t = TestFormat::Rich)]
    pub format: TestFormat,
    /// Compile a debug build and launch the test runner under Node's
    /// inspector (`node --inspect-brk`), printing the inspector URL for a
    /// JavaScript debugger to attach. The emitted `.ts` runs directly under
    /// Node's line-preserving type-stripping, so source maps resolve
    /// breakpoints back to `.bynk`. Requires Node ≥ 22.18 (or ≥ 23.6
    /// unflagged). Does not run `tsc`.
    #[arg(long)]
    pub inspect: bool,
    /// The root seed for generative `property` tests, as hex (e.g. `0x5f3a`).
    /// A failing property prints the seed it used; re-running with `--seed
    /// <hex>` reproduces that run byte-for-byte. Omitted, each run draws a
    /// fresh random seed.
    #[arg(long)]
    pub seed: Option<String>,
    /// Run only test cases whose name matches `<name>`, skipping the rest —
    /// the filter behind the editor's per-case `▷ Run Test` lens. Matches by
    /// exact case name across suites; omitted, every case runs. No effect
    /// with `--no-run` (discovery lists all cases regardless).
    #[arg(long, value_name = "NAME")]
    pub case: Option<String>,
    /// After the suite runs, report statement/line coverage attributed to
    /// `.bynk` source (a rich summary table, or a `coverage` block in
    /// `--format json`). Requires the `tsc → node` path: incompatible with
    /// `--inspect` and `--no-run`, and errors if only `tsx` is available.
    #[arg(long)]
    pub coverage: bool,
}

/// Normalise a `--seed` value (`0x5f3a` or `5f3a`) to the bare-hex form the
/// runner reads from `BYNK_TEST_SEED` (JS `parseInt(_, 16)` does not accept a
/// `0x` prefix). Returns `None` for a non-hex value, so a typo is ignored rather
/// than silently seeding to zero.
fn normalise_seed(raw: &str) -> Option<String> {
    let hex = raw
        .strip_prefix("0x")
        .or_else(|| raw.strip_prefix("0X"))
        .unwrap_or(raw);
    if hex.is_empty() || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
        return None;
    }
    Some(hex.to_string())
}

/// In `--format json` mode the deterministic surface is the document on stdout,
/// so a `<program> test:` line on stderr is fine but must never reach stdout.
/// `program` prefixes stderr messages (`"bynkc"` or `"bynk"`) so they read the
/// same as before the move.
pub fn run_test(program: &str, args: TestArgs) -> ExitCode {
    let TestArgs {
        input,
        output,
        no_run,
        format,
        inspect,
        seed,
        case,
        coverage,
    } = args;
    let json = format == TestFormat::Json;
    // #854 DECISION C: `--coverage` requires the `tsc → node` path — the CI-shaped
    // path with real `.js.map`s. `--inspect` is a debug path with no `tsc` (and a
    // different map role), and `--no-run` never launches a process to observe.
    // Reject both up front with an actionable message rather than producing
    // silently-wrong or empty numbers.
    if coverage && inspect {
        return coverage_unsupported(
            program,
            json,
            "`--coverage` cannot be combined with `--inspect` — coverage needs the `tsc → node` run, not the inspector.",
        );
    }
    if coverage && no_run {
        return coverage_unsupported(
            program,
            json,
            "`--coverage` cannot be combined with `--no-run` — there is no run to measure.",
        );
    }
    // v0.127 (editor-currency slice 6): the per-case run filter. An empty
    // `--case` is treated as unset (run all) rather than "match the empty name".
    let case_filter = case.filter(|c| !c.is_empty());
    // v0.114: the root seed for generative `property` tests, threaded to the
    // runner via `BYNK_TEST_SEED` (bare hex). An unparseable value is dropped
    // with a warning so a run still proceeds with a fresh seed.
    let seed_hex = match seed.as_deref() {
        Some(raw) => match normalise_seed(raw) {
            Some(hex) => Some(hex),
            None => {
                if !json {
                    eprintln!(
                        "{program} test: ignoring --seed `{raw}` (not a hex value like 0x5f3a)"
                    );
                }
                None
            }
        },
        None => None,
    };
    let output_root = output.unwrap_or_else(|| input.join("out"));
    if !input.is_dir() {
        eprintln!(
            "{program} test: input `{}` must be a project directory containing `.bynk` files",
            input.display()
        );
        return ExitCode::FAILURE;
    }
    // v0.9.1: rooting strategy (#46: shared with check/compile via
    // `project_options`) — a `bynk.toml` or `src/` subdir selects split-paths
    // mode (sources under `[paths] src`, tests under `[paths] tests`); else the
    // legacy single-tree where `<input>` is both the source and tests root.
    // `--inspect` compiles a debug build: `.ts` import specifiers so the emitted
    // entry runs directly under Node's strip-only type-stripping (slice 2), where
    // slice 1's source maps apply unchanged. A normal run keeps `.js` specifiers
    // for the `tsc → node` path.
    let options = {
        // v0.115: `test` compiles the dev/test profile — the function
        // contract call-site guard is emitted (DECISION J). `compile`
        // leaves it off, so contract checks never reach production.
        let o = match crate::try_project_options(&input) {
            Ok(o) => o.contracts(true),
            Err(e) => {
                if json {
                    print!("{}", TestRun::runtime_error(e.to_string(), None).render());
                } else {
                    eprintln!("{program} test: {e}");
                }
                return ExitCode::FAILURE;
            }
        };
        if inspect {
            o.import_ext(ImportExt::Ts)
        } else {
            o
        }
    };
    let out = match bynk_emit::project::compile_project(&options) {
        Ok(out) => out,
        Err(failure) => {
            if json {
                print!(
                    "{}",
                    TestRun::compile_error(crate::project_failure_short_lines(&failure)).render()
                );
            } else {
                crate::print_project_failure(&failure);
            }
            return ExitCode::FAILURE;
        }
    };
    // v0.67: `--no-run --format json` is pure discovery — render the suite/case
    // manifest the compile retained and stop. No TS is written, no `tsc`/`node`
    // runs, and the integration workers re-compile below is skipped (the manifest
    // already carries integration suites from the compile above). A compile
    // failure took the `compile`-error path above, exactly as a run would.
    if no_run && json {
        print!("{}", TestRun::discovered(discovery_suites(&out)).render());
        return ExitCode::SUCCESS;
    }

    // Write every compiled file to disk under the output root.
    let mut wrote_any_test = false;
    let mut has_integration = false;
    for file in &out.files {
        // Map-aware write (slice 2): carries the `.ts.map` siblings + trailers so
        // a debug run (`--inspect`) can resolve `.bynk` breakpoints. Harmless for a
        // normal run, which transpiles via `tsc` and ignores the trailer.
        if let Err(e) = bynk_emit::write_compiled_file(file, &output_root) {
            eprintln!(
                "{program} test: could not write `{}`: {e}",
                output_root.join(&file.output_path).display()
            );
            return ExitCode::FAILURE;
        }
        let rel = file.output_path.to_string_lossy();
        if rel.starts_with("tests/") {
            wrote_any_test = true;
        }
        if rel.starts_with("tests/integration_") {
            has_integration = true;
        }
    }

    // v0.16: integration tests stand their participants up as real Workers, so
    // they import the workers-mode output (`workers/**`) and the serialise/
    // deserialise helpers the workers commons emit. The bundle compile above
    // does not produce those, so run a second compile in workers mode and
    // overlay everything except the `tests/` tree (whose unit modules import
    // the bundle output). The workers commons are a strict superset of the
    // bundle ones, so overwriting them is safe for the bundle code too.
    if has_integration {
        // v0.115/slice 2: reuse `options` (not a fresh `project_options(&input)`)
        // so this second compile keeps `contracts(true)` and, under `--inspect`,
        // `import_ext(Ts)` — a from-scratch rebuild silently dropped both.
        let workers_out =
            bynk_emit::project::compile_project(&options.clone().target(BuildTarget::Workers));
        let workers_out = match workers_out {
            Ok(o) => o,
            Err(failure) => {
                if json {
                    print!(
                        "{}",
                        TestRun::compile_error(crate::project_failure_short_lines(&failure))
                            .render()
                    );
                } else {
                    crate::print_project_failure(&failure);
                }
                return ExitCode::FAILURE;
            }
        };
        for file in &workers_out.files {
            if file.output_path.to_string_lossy().starts_with("tests/") {
                continue;
            }
            if let Err(e) = bynk_emit::write_compiled_file(file, &output_root) {
                eprintln!(
                    "{program} test: could not write `{}`: {e}",
                    output_root.join(&file.output_path).display()
                );
                return ExitCode::FAILURE;
            }
        }
    }

    if !wrote_any_test {
        if json {
            print!("{}", empty_run().render());
        } else {
            eprintln!(
                "{program} test: no test declarations found in `{}`",
                input.display()
            );
        }
        return ExitCode::SUCCESS;
    }

    let main_ts = output_root.join("tests").join("main.ts");
    if no_run {
        // Rich `--no-run` is the CI emit helper: write the runner modules and
        // report where they landed. (JSON `--no-run` already returned above with
        // the discovery document — it never reaches here.)
        eprintln!("{program} test: tests emitted to {}", main_ts.display());
        return ExitCode::SUCCESS;
    }

    // Slice 2 (ADR 0104): launch the emitted `.ts` test entry directly under
    // Node's inspector. No `tsc` — the `.ts` runs under line-preserving
    // type-stripping, so the source maps written above resolve `.bynk`
    // breakpoints. Node prints its inspector URL; a debugger attaches there.
    if inspect {
        return run_inspect(
            program,
            &main_ts,
            seed_hex.as_deref(),
            case_filter.as_deref(),
        );
    }

    let tsconfig = output_root.join("tsconfig.json");
    // #854: coverage needs tsc's `.js.map`s (remap hop 1). Overwrite the default
    // tsconfig the compile wrote with the `sourceMap: true` variant, kept
    // coverage-only so a normal test run / deployment build ships no maps.
    if coverage
        && let Err(e) = std::fs::write(
            &tsconfig,
            bynk_emit::emitter::emit_tsconfig_with_source_maps(),
        )
    {
        return coverage_unsupported(
            program,
            json,
            format!("could not enable source maps for coverage: {e}"),
        );
    }
    // Preferred: `tsc -p out/tsconfig.json` → `node out-js/tests/main.js`.
    // tsc gives us full type-checking before execution and matches what a
    // production deployment build would do. If tsc is missing, fall back to
    // tsx, which compiles-and-runs in one step. We also try npx-mediated
    // variants so a developer with `npm` available doesn't need a global
    // install. If nothing works, emit an actionable error message.
    let out_js_root = output_root
        .parent()
        .map(|p| p.join("out-js"))
        .unwrap_or_else(|| PathBuf::from("out-js"));
    let main_js = out_js_root.join("tests").join("main.js");

    // Try a sequence of (program, prefix args) tsc invocations. In JSON mode the
    // tsc step is captured so its output never reaches stdout (the document is
    // the only thing on stdout); a tsc failure on the emitted TS is a
    // toolchain/internal problem, surfaced as a `runtime` error.
    let tsc_runners: Vec<(&str, Vec<&str>)> = vec![
        ("tsc", vec![]),
        ("npx", vec!["--yes", "-p", "typescript@5", "tsc"]),
    ];
    for (prog, prefix) in &tsc_runners {
        if !tool_exists(prog) {
            continue;
        }
        let mut cmd = ProcCommand::new(prog);
        for p in prefix {
            cmd.arg(p);
        }
        cmd.arg("-p").arg(&tsconfig);
        let tsc_ok = if json {
            match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output() {
                Ok(out) if out.status.success() => true,
                Ok(out) => {
                    // tsc writes its diagnostics to *stdout*; capturing only
                    // stderr left the document's most useful field empty.
                    let mut detail = String::from_utf8_lossy(&out.stdout).into_owned();
                    let err_text = String::from_utf8_lossy(&out.stderr);
                    if !err_text.trim().is_empty() {
                        if !detail.is_empty() {
                            detail.push('\n');
                        }
                        detail.push_str(&err_text);
                    }
                    print!(
                        "{}",
                        TestRun::runtime_error(
                            "tsc rejected the generated TypeScript",
                            Some(detail),
                        )
                        .render()
                    );
                    return ExitCode::FAILURE;
                }
                Err(_) => continue,
            }
        } else {
            match cmd
                .stdout(Stdio::inherit())
                .stderr(Stdio::inherit())
                .status()
            {
                Ok(s) if s.success() => true,
                Ok(_) => {
                    eprintln!(
                        "{program} test: tsc reported errors against {}",
                        tsconfig.display()
                    );
                    return ExitCode::FAILURE;
                }
                Err(_) => continue,
            }
        };
        if tsc_ok {
            let mut node_cmd = ProcCommand::new("node");
            node_cmd.arg(&main_js);
            // #854: the coverage path owns the node launch (it sets
            // `NODE_V8_COVERAGE` and reads the result back), so it does not go
            // through `finish_runner`.
            if coverage {
                return run_with_coverage(
                    program,
                    node_cmd,
                    json,
                    seed_hex.as_deref(),
                    case_filter.as_deref(),
                    &out_js_root,
                    &output_root,
                    &input,
                );
            }
            return match finish_runner(node_cmd, json, seed_hex.as_deref(), case_filter.as_deref())
            {
                Ok(code) => code,
                Err(e) => {
                    if json {
                        print!(
                            "{}",
                            TestRun::runtime_error(format!("could not run node: {e}"), None)
                                .render()
                        );
                    } else {
                        eprintln!(
                            "{program} test: tsc succeeded but `node {}` failed: {e}",
                            main_js.display()
                        );
                    }
                    ExitCode::FAILURE
                }
            };
        }
    }

    // #854 DECISION C: with `--coverage`, the `tsc → node` path is required — do
    // not silently fall through to `tsx`, whose on-the-fly transform muddies
    // which map applies. Fail clearly instead.
    if coverage {
        return coverage_unsupported(
            program,
            json,
            "`--coverage` requires `tsc` and `node` on PATH (the CI-shaped path with `.js.map`s); the `tsx` fallback is not supported for coverage.",
        );
    }

    // tsx fallback chain.
    let tsx_runners: Vec<(&str, Vec<&str>)> = vec![("tsx", vec![]), ("npx", vec!["--yes", "tsx"])];
    for (prog, prefix) in &tsx_runners {
        if !tool_exists(prog) {
            continue;
        }
        let mut cmd = ProcCommand::new(prog);
        for p in prefix {
            cmd.arg(p);
        }
        cmd.arg(&main_ts);
        match finish_runner(cmd, json, seed_hex.as_deref(), case_filter.as_deref()) {
            Ok(code) => return code,
            Err(_) => continue,
        }
    }

    if json {
        print!(
            "{}",
            TestRun::runtime_error(
                "no test runner found: requires `tsc` (with Node.js) or `tsx` on PATH",
                None
            )
            .render()
        );
    } else {
        eprintln!(
            "{program} test: requires either `tsc` (with Node.js) or `tsx` on PATH. \
             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.",
        );
    }
    ExitCode::FAILURE
}

/// A normal run with no suites — the JSON-mode document for a project with no
/// tests, or `--no-run`.
fn empty_run() -> TestRun {
    TestRun::empty()
}

/// v0.67: map the compile's retained test manifest into discovery [`Suite`]s for
/// the `--no-run --format json` document. Each case is `outcome: "discovered"`,
/// carrying its declaration `location` (when known) for editor click-through.
fn discovery_suites(out: &ProjectOutput) -> Vec<Suite> {
    out.discovered
        .iter()
        .map(|s| Suite {
            name: s.name.clone(),
            kind: s.kind.to_string(),
            cases: s
                .cases
                .iter()
                .map(|c| Case {
                    name: c.name.clone(),
                    outcome: "discovered".to_string(),
                    message: None,
                    location: c.location.as_ref().map(|l| Location {
                        path: l.path.clone(),
                        line: l.line,
                        col: l.col,
                    }),
                })
                .collect(),
        })
        .collect()
}

/// Execute the built runner command and produce its exit code. In JSON mode the
/// runner's stdout (NDJSON) and stderr are captured, folded into the pinned
/// document, and printed; otherwise stdio is inherited so the human ✓ / ✗ output
/// flows straight through. Either way the **exit code follows the runner's own
/// process status**, so a mid-run crash (a complete NDJSON prefix but no
/// `run-end`) is never reported as success.
fn finish_runner(
    mut cmd: ProcCommand,
    json: bool,
    seed_hex: Option<&str>,
    case: Option<&str>,
) -> std::io::Result<ExitCode> {
    if let Some(hex) = seed_hex {
        cmd.env("BYNK_TEST_SEED", hex);
    }
    if let Some(name) = case {
        cmd.env("BYNK_TEST_CASE", name);
    }
    if json {
        cmd.env("BYNK_TEST_FORMAT", "ndjson");
        let out = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output()?;
        let stdout = String::from_utf8_lossy(&out.stdout);
        let stderr = String::from_utf8_lossy(&out.stderr);
        let doc = crate::test_json::parse_ndjson(&stdout).into_document(&stderr);
        print!("{}", doc.render());
        Ok(exit_from(out.status.success()))
    } else {
        let status = cmd
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .status()?;
        Ok(exit_from(status.success()))
    }
}

fn exit_from(success: bool) -> ExitCode {
    if success {
        ExitCode::SUCCESS
    } else {
        ExitCode::FAILURE
    }
}

/// #854: a `--coverage` request that cannot be honoured (unsupported flag combo,
/// no `tsc → node`, or a setup error). In JSON mode it is a `runtime` error so
/// the document stays the only thing on stdout; in rich mode a plain stderr line.
fn coverage_unsupported(program: &str, json: bool, message: impl Into<String>) -> ExitCode {
    let message = message.into();
    if json {
        print!("{}", TestRun::runtime_error(message, None).render());
    } else {
        eprintln!("{program} test --coverage: {message}");
    }
    ExitCode::FAILURE
}

/// #854: run the emitted runner under V8 coverage and attribute the result to
/// `.bynk` source. Owns the `node` launch: it points `NODE_V8_COVERAGE` at a
/// scratch dir, runs the suite exactly as [`finish_runner`] would, then remaps
/// the V8 output through the emitted source maps ([`crate::coverage`]). In rich
/// mode the summary table is appended after the human ✓ / ✗ output; in JSON mode
/// the `coverage` block is folded into the pinned document. The **exit code
/// follows the run's own status** — coverage is a report about the run, never a
/// gate on it (a partial or unreadable map degrades the numbers, not the code).
#[allow(clippy::too_many_arguments)]
fn run_with_coverage(
    program: &str,
    mut cmd: ProcCommand,
    json: bool,
    seed_hex: Option<&str>,
    case: Option<&str>,
    out_js_root: &Path,
    out_root: &Path,
    source_root: &Path,
) -> ExitCode {
    // A scratch dir beside the build output; cleared first so a prior run's JSON
    // never leaks in. `NODE_V8_COVERAGE` writes one file per process on exit.
    let cov_dir = out_root.join(".v8-coverage");
    let _ = std::fs::remove_dir_all(&cov_dir);
    if let Err(e) = std::fs::create_dir_all(&cov_dir) {
        return coverage_unsupported(
            program,
            json,
            format!("could not create the coverage dir: {e}"),
        );
    }
    cmd.env("NODE_V8_COVERAGE", &cov_dir);
    if let Some(hex) = seed_hex {
        cmd.env("BYNK_TEST_SEED", hex);
    }
    if let Some(name) = case {
        cmd.env("BYNK_TEST_CASE", name);
    }

    let collect = || {
        crate::coverage::collect_coverage(&cov_dir, out_js_root, out_root, source_root)
            .unwrap_or_default()
    };

    let code = if json {
        cmd.env("BYNK_TEST_FORMAT", "ndjson");
        let out = match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output() {
            Ok(o) => o,
            Err(e) => {
                let _ = std::fs::remove_dir_all(&cov_dir);
                return coverage_unsupported(program, true, format!("could not run node: {e}"));
            }
        };
        let stdout = String::from_utf8_lossy(&out.stdout);
        let stderr = String::from_utf8_lossy(&out.stderr);
        let report = collect();
        let doc = crate::test_json::parse_ndjson(&stdout)
            .into_document(&stderr)
            .with_coverage(&report);
        print!("{}", doc.render());
        exit_from(out.status.success())
    } else {
        let status = cmd
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .status();
        let status = match status {
            Ok(s) => s,
            Err(e) => {
                let _ = std::fs::remove_dir_all(&cov_dir);
                eprintln!("{program} test --coverage: could not run node: {e}");
                return ExitCode::FAILURE;
            }
        };
        let report = collect();
        print!("{}", crate::coverage::render_rich(&report));
        exit_from(status.success())
    };
    let _ = std::fs::remove_dir_all(&cov_dir);
    code
}

/// Slice 2 (ADR 0104): launch the emitted `.ts` test entry under Node's inspector
/// and hand off. Node prints its inspector `ws://` URL to stderr and pauses at the
/// first line (`--inspect-brk`) until a JavaScript debugger attaches; breakpoints
/// set in `.bynk` resolve through the emitted source maps. `--experimental-strip-types`
/// runs the `.ts` directly under line-preserving type-stripping (Node ≥ 22.6;
/// unflagged ≥ 23.6) — no `tsc`, so slice 1's `.ts.map` applies to the running file.
fn run_inspect(
    program: &str,
    entry: &Path,
    seed_hex: Option<&str>,
    case: Option<&str>,
) -> ExitCode {
    if !tool_exists("node") {
        eprintln!("{program} test --inspect: `node` was not found on PATH");
        return ExitCode::FAILURE;
    }
    eprintln!("{program} test --inspect: launching the test runner under Node's inspector.");
    eprintln!("  Attach a JavaScript debugger to the inspector URL below; breakpoints set");
    eprintln!("  in `.bynk` sources resolve through the emitted source maps.");
    eprintln!("  (Requires Node \u{2265} 22.6 for TypeScript type-stripping.)");
    let mut cmd = ProcCommand::new("node");
    if let Some(hex) = seed_hex {
        cmd.env("BYNK_TEST_SEED", hex);
    }
    if let Some(name) = case {
        cmd.env("BYNK_TEST_CASE", name);
    }
    cmd.arg("--experimental-strip-types")
        .arg("--inspect-brk")
        .arg(entry);
    match cmd
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
    {
        Ok(s) => exit_from(s.success()),
        Err(e) => {
            eprintln!("{program} test --inspect: could not run node: {e}");
            ExitCode::FAILURE
        }
    }
}