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    /// Run the `system` suites against a real runtime instead of in process:
123    /// `dev`, a local `bynk dev` the runner starts itself (through `bynk
124    /// test` only), or a target named under `[test.targets]` in `bynk.toml`.
125    /// A bare URL is refused, and so is a target marked `production = true`.
126    /// A named target's secrets are read from the environment. Only the
127    /// `system` suites run, and a case that uses what a real runtime can't
128    /// honour (an `on call` address, `advance Clock`, a cron or queue address,
129    /// an asset fetch) is refused unless `--skip-unsupported` is passed.
130    #[arg(long, value_name = "TARGET")]
131    pub against: Option<String>,
132    /// With `--against`, skip each `system` case a real runtime can't
133    /// honour, naming it and why, instead of refusing the run.
134    #[arg(long, requires = "against")]
135    pub skip_unsupported: bool,
136    /// With `--against`, how long a failing `expect` is retried before it
137    /// fails, for each retried stretch: the addresses since the last unsafe one (anything but `GET`,
138    /// `HEAD` or `OPTIONS`) are re-run before each retry, so a real system's
139    /// latency doesn't fail a case. A duration such as `5s`, `500ms` or `0`
140    /// (no retry).
141    #[arg(
142        long,
143        value_name = "DURATION",
144        requires = "against",
145        default_value = "5s"
146    )]
147    pub settle: Option<String>,
148}
149
150/// Where `test` writes its output: `-o`, or `<input>/out`.
151fn output_root_for(input: &Path, output: Option<&Path>) -> PathBuf {
152    output.map_or_else(|| input.join("out"), Path::to_path_buf)
153}
154
155/// Normalise a `--seed` value (`0x5f3a` or `5f3a`) to the bare-hex form the
156/// runner reads from `BYNK_TEST_SEED` (JS `parseInt(_, 16)` does not accept a
157/// `0x` prefix). Returns `None` for a non-hex value, so a typo is ignored rather
158/// than silently seeding to zero.
159fn normalise_seed(raw: &str) -> Option<String> {
160    let hex = raw
161        .strip_prefix("0x")
162        .or_else(|| raw.strip_prefix("0X"))
163        .unwrap_or(raw);
164    if hex.is_empty() || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
165        return None;
166    }
167    Some(hex.to_string())
168}
169
170/// In `--format json` mode the deterministic surface is the document on stdout,
171/// so a `<program> test:` line on stderr is fine but must never reach stdout.
172/// `program` prefixes stderr messages (`"bynkc"` or `"bynk"`) so they read the
173/// same as before the move.
174pub fn run_test(program: &str, args: TestArgs) -> ExitCode {
175    let TestArgs {
176        input,
177        output,
178        no_run,
179        format,
180        inspect,
181        seed,
182        case,
183        coverage,
184        against,
185        skip_unsupported,
186        settle,
187    } = args;
188    let json = format == TestFormat::Json;
189    // #2138: `--against` — resolve and check the target before anything is
190    // built, so a refused run makes no request. `dev` is the `bynk` driver's
191    // to start (`bynk test --against dev`); here it is refused.
192    let mut remote = match against.as_deref() {
193        None => None,
194        Some(value) => {
195            let refused = |message: String| {
196                if json {
197                    print!("{}", TestRun::runtime_error(message, None).render());
198                } else {
199                    eprintln!("{program} test: {message}");
200                }
201                ExitCode::FAILURE
202            };
203            let settle_ms = match settle.as_deref().map(crate::remote::parse_settle) {
204                None => crate::remote::DEFAULT_SETTLE_MS,
205                Some(Ok(ms)) => ms,
206                Some(Err(e)) => return refused(e),
207            };
208            if inspect || coverage {
209                return refused(
210                    "`--against` cannot be combined with `--inspect` or `--coverage`, which observe the in-process run".to_string(),
211                );
212            }
213            match crate::remote::resolve(
214                value,
215                &input,
216                |name| std::env::var(name).ok(),
217                "starts a local `bynk dev`, which only the `bynk` driver can do: run `bynk test --against dev`",
218            ) {
219                Ok(crate::remote::Against::Run(run)) => {
220                    Some(crate::remote::RemoteRun { settle_ms, ..run })
221                }
222                Ok(crate::remote::Against::StartDev(bynk)) => {
223                    return crate::dev_run::run(
224                        program,
225                        &crate::dev_run::DevRunArgs {
226                            input: input.clone(),
227                            output: output_root_for(&input, output.as_deref()),
228                            json,
229                            seed: seed.clone(),
230                            case: case.clone(),
231                            skip_unsupported,
232                            settle: settle.clone(),
233                            run_id: crate::remote::fresh_run_id(),
234                            bynk,
235                        },
236                    );
237                }
238                Err(refusal) => return refused(refusal.to_string()),
239            }
240        }
241    };
242    // #854 DECISION C: `--coverage` requires the `tsc → node` path — the CI-shaped
243    // path with real `.js.map`s. `--inspect` is a debug path with no `tsc` (and a
244    // different map role), and `--no-run` never launches a process to observe.
245    // Reject both up front with an actionable message rather than producing
246    // silently-wrong or empty numbers.
247    if coverage && inspect {
248        return coverage_unsupported(
249            program,
250            json,
251            "`--coverage` cannot be combined with `--inspect` — coverage needs the `tsc → node` run, not the inspector.",
252        );
253    }
254    if coverage && no_run {
255        return coverage_unsupported(
256            program,
257            json,
258            "`--coverage` cannot be combined with `--no-run` — there is no run to measure.",
259        );
260    }
261    // v0.127 (editor-currency slice 6): the per-case run filter. An empty
262    // `--case` is treated as unset (run all) rather than "match the empty name".
263    let case_filter = case.filter(|c| !c.is_empty());
264    // v0.114: the root seed for generative `property` tests, threaded to the
265    // runner via `BYNK_TEST_SEED` (bare hex). An unparseable value is dropped
266    // with a warning so a run still proceeds with a fresh seed.
267    let seed_hex = match seed.as_deref() {
268        Some(raw) => match normalise_seed(raw) {
269            Some(hex) => Some(hex),
270            None => {
271                if !json {
272                    eprintln!(
273                        "{program} test: ignoring --seed `{raw}` (not a hex value like 0x5f3a)"
274                    );
275                }
276                None
277            }
278        },
279        None => None,
280    };
281    let output_root = output_root_for(&input, output.as_deref());
282    if !input.is_dir() {
283        eprintln!(
284            "{program} test: input `{}` must be a project directory containing `.bynk` files",
285            input.display()
286        );
287        return ExitCode::FAILURE;
288    }
289    // v0.9.1: rooting strategy (#46: shared with check/compile via
290    // `project_options`) — a `bynk.toml` or `src/` subdir selects split-paths
291    // mode (sources under `[paths] src`, tests under `[paths] tests`); else the
292    // legacy single-tree where `<input>` is both the source and tests root.
293    // `--inspect` compiles a debug build: `.ts` import specifiers so the emitted
294    // entry runs directly under Node's strip-only type-stripping (slice 2), where
295    // slice 1's source maps apply unchanged. A normal run keeps `.js` specifiers
296    // for the `tsc → node` path.
297    let options = {
298        // v0.115: `test` compiles the dev/test profile — the function
299        // contract call-site guard is emitted (DECISION J). `compile`
300        // leaves it off, so contract checks never reach production.
301        // #2078: reconcile events against the committed `bynk.schema.lock`,
302        // read-only — a non-additive change fails `test` as it fails a
303        // build, and `out.schema_lock` is never written back.
304        let o = match crate::try_project_options(&input)
305            .map_err(|e| e.to_string())
306            .and_then(|o| Ok(o.schema_registry(crate::schema_lock::read_only(&input)?)))
307        {
308            Ok(o) => o.contracts(true),
309            Err(e) => {
310                if json {
311                    print!("{}", TestRun::runtime_error(e, None).render());
312                } else {
313                    eprintln!("{program} test: {e}");
314                }
315                return ExitCode::FAILURE;
316            }
317        };
318        // #2138: a remote run emits only the `system` suites, driven at
319        // the target's URLs.
320        let o = o.remote_tests(remote.as_ref().map(|run| bynk_emit::project::RemoteTests {
321            skip_unsupported,
322            vars: run.vars.clone(),
323            secrets: run.secrets.clone(),
324            dev: run.dev_env.is_some(),
325            only_env: run.dev_env.clone(),
326        }));
327        if inspect {
328            o.import_ext(ImportExt::Ts)
329        } else {
330            o
331        }
332    };
333    let out = match bynk_emit::project::compile_project(&options) {
334        Ok(out) => out,
335        Err(failure) => {
336            if json {
337                print!(
338                    "{}",
339                    TestRun::compile_error(crate::project_failure_short_lines(&failure)).render()
340                );
341            } else {
342                crate::print_project_failure(&failure);
343            }
344            return ExitCode::FAILURE;
345        }
346    };
347    // v0.67: `--no-run --format json` is pure discovery — render the suite/case
348    // manifest the compile retained and stop. No TS is written, no `tsc`/`node`
349    // runs, and the integration workers re-compile below is skipped (the manifest
350    // already carries integration suites from the compile above). A compile
351    // failure took the `compile`-error path above, exactly as a run would.
352    if no_run && json {
353        print!("{}", TestRun::discovered(discovery_suites(&out)).render());
354        return ExitCode::SUCCESS;
355    }
356    // #2138: the cases the remote build skipped, and the target, named before
357    // the first request.
358    if let Some(run) = remote.as_mut() {
359        run.skipped = out
360            .discovered
361            .iter()
362            .flat_map(|s| s.skipped.iter().map(|k| (s.name.clone(), k.clone())))
363            .collect();
364        eprint!("{program} test: {}", run.banner());
365        if !json {
366            for (suite, skip) in &run.skipped {
367                eprintln!(
368                    "{program} test: skipped `{suite}` case \"{}\" [{}]: {}",
369                    skip.case, skip.code, skip.reason
370                );
371            }
372        }
373    }
374
375    // Write every artefact to disk under the output root.
376    let mut wrote_any_test = false;
377    let mut has_integration = false;
378    for (path, doc) in &out.artefacts.docs {
379        // Map-aware write (slice 2): carries the `.ts.map` siblings + trailers so
380        // a debug run (`--inspect`) can resolve `.bynk` breakpoints. Harmless for a
381        // normal run, which transpiles via `tsc` and ignores the trailer.
382        if let Err(e) = crate::write_document(path, doc, &out.artefacts.docs, &output_root) {
383            eprintln!(
384                "{program} test: could not write `{}`: {e}",
385                output_root.join(path).display()
386            );
387            return ExitCode::FAILURE;
388        }
389        let rel = path.to_string_lossy();
390        if rel.starts_with("tests/") {
391            wrote_any_test = true;
392        }
393        if rel.starts_with("tests/integration_") {
394            has_integration = true;
395        }
396    }
397
398    // v0.16: integration tests stand their participants up as real Workers, so
399    // they import the workers-mode output (`workers/**`) and the serialise/
400    // deserialise helpers the workers commons emit. The bundle compile above
401    // does not produce those, so run a second compile in workers mode and
402    // overlay everything except the `tests/` tree (whose unit modules import
403    // the bundle output). The workers commons are a strict superset of the
404    // bundle ones, so overwriting them is safe for the bundle code too.
405    if has_integration {
406        // v0.115/slice 2: reuse `options` (not a fresh `project_options(&input)`)
407        // so this second compile keeps `contracts(true)` and, under `--inspect`,
408        // `import_ext(Ts)` — a from-scratch rebuild silently dropped both.
409        let workers_out =
410            bynk_emit::project::compile_project(&options.clone().target(BuildTarget::Workers));
411        let workers_out = match workers_out {
412            Ok(o) => o,
413            Err(failure) => {
414                if json {
415                    print!(
416                        "{}",
417                        TestRun::compile_error(crate::project_failure_short_lines(&failure))
418                            .render()
419                    );
420                } else {
421                    crate::print_project_failure(&failure);
422                }
423                return ExitCode::FAILURE;
424            }
425        };
426        for (path, doc) in &workers_out.artefacts.docs {
427            if path.to_string_lossy().starts_with("tests/") {
428                continue;
429            }
430            if let Err(e) =
431                crate::write_document(path, doc, &workers_out.artefacts.docs, &output_root)
432            {
433                eprintln!(
434                    "{program} test: could not write `{}`: {e}",
435                    output_root.join(path).display()
436                );
437                return ExitCode::FAILURE;
438            }
439        }
440    }
441
442    if !wrote_any_test {
443        if json {
444            print!("{}", empty_run().render());
445        } else {
446            eprintln!(
447                "{program} test: no test declarations found in `{}`",
448                input.display()
449            );
450        }
451        return ExitCode::SUCCESS;
452    }
453
454    let main_ts = output_root.join("tests").join("main.ts");
455    if no_run {
456        // Rich `--no-run` is the CI emit helper: write the runner modules and
457        // report where they landed. (JSON `--no-run` already returned above with
458        // the discovery document — it never reaches here.)
459        eprintln!("{program} test: tests emitted to {}", main_ts.display());
460        return ExitCode::SUCCESS;
461    }
462
463    // Slice 2 (ADR 0104): launch the emitted `.ts` test entry directly under
464    // Node's inspector. No `tsc` — the `.ts` runs under line-preserving
465    // type-stripping, so the source maps written above resolve `.bynk`
466    // breakpoints. Node prints its inspector URL; a debugger attaches there.
467    if inspect {
468        return run_inspect(
469            program,
470            &main_ts,
471            seed_hex.as_deref(),
472            case_filter.as_deref(),
473        );
474    }
475
476    let tsconfig = output_root.join("tsconfig.json");
477    // #854: coverage needs tsc's `.js.map`s (remap hop 1). Overwrite the default
478    // tsconfig the compile wrote with the `sourceMap: true` variant, kept
479    // coverage-only so a normal test run / deployment build ships no maps.
480    if coverage
481        && let Err(e) = std::fs::write(
482            &tsconfig,
483            bynk_emit::emitter::emit_tsconfig_with_source_maps(),
484        )
485    {
486        return coverage_unsupported(
487            program,
488            json,
489            format!("could not enable source maps for coverage: {e}"),
490        );
491    }
492    // Preferred: `tsc -p out/tsconfig.json` → `node out-js/tests/main.js`.
493    // tsc gives us full type-checking before execution and matches what a
494    // production deployment build would do. If tsc is missing, fall back to
495    // tsx, which compiles-and-runs in one step. We also try npx-mediated
496    // variants so a developer with `npm` available doesn't need a global
497    // install. If nothing works, emit an actionable error message.
498    let out_js_root = output_root
499        .parent()
500        .map(|p| p.join("out-js"))
501        .unwrap_or_else(|| PathBuf::from("out-js"));
502    let main_js = out_js_root.join("tests").join("main.js");
503
504    // #2146: tsc emits ES modules (the compile writes `out/package.json`
505    // declaring `"type": "module"`, which the emitted tsconfig's `module:
506    // NodeNext` follows, #2148), but `out-js` sits in no such `package.json`
507    // scope, so mark it ESM for Node to run the result as such. The `out/`
508    // tree is left as the compiler wrote it.
509    if let Err(e) = write_esm_marker(&out_js_root) {
510        let msg = format!(
511            "could not write {}: {e}",
512            out_js_root.join("package.json").display()
513        );
514        if json {
515            print!("{}", TestRun::runtime_error(msg, None).render());
516        } else {
517            eprintln!("{program} test: {msg}");
518        }
519        return ExitCode::FAILURE;
520    }
521
522    // Try a sequence of (program, prefix args) tsc invocations. In JSON mode the
523    // tsc step is captured so its output never reaches stdout (the document is
524    // the only thing on stdout); a tsc failure on the emitted TS is a
525    // toolchain/internal problem, surfaced as a `runtime` error.
526    // #1672: the `npx` fallback provisions the TypeScript major the output is
527    // verified against and users get, not an older pin.
528    let typescript_pkg = format!("typescript@{}", bynk_emit::TYPESCRIPT_MAJOR_TESTED);
529    let mut start_failures: Vec<String> = Vec::new();
530    let tsc_runners: Vec<(&str, Vec<&str>)> = vec![
531        ("tsc", vec![]),
532        ("npx", vec!["--yes", "-p", typescript_pkg.as_str(), "tsc"]),
533    ];
534    for (prog, prefix) in &tsc_runners {
535        let Some(path) = resolve_tool(prog) else {
536            continue;
537        };
538        let mut cmd = ProcCommand::new(&path);
539        for p in prefix {
540            cmd.arg(p);
541        }
542        cmd.arg("-p").arg(&tsconfig);
543        let tsc_ok = if json {
544            match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output() {
545                Ok(out) if out.status.success() => true,
546                Ok(out) => {
547                    // tsc writes its diagnostics to *stdout*; capturing only
548                    // stderr left the document's most useful field empty.
549                    let mut detail = String::from_utf8_lossy(&out.stdout).into_owned();
550                    let err_text = String::from_utf8_lossy(&out.stderr);
551                    if !err_text.trim().is_empty() {
552                        if !detail.is_empty() {
553                            detail.push('\n');
554                        }
555                        detail.push_str(&err_text);
556                    }
557                    print!(
558                        "{}",
559                        TestRun::runtime_error(
560                            "tsc rejected the generated TypeScript",
561                            Some(detail),
562                        )
563                        .render()
564                    );
565                    return ExitCode::FAILURE;
566                }
567                Err(e) => {
568                    start_failures.push(start_failure(&path, &e));
569                    continue;
570                }
571            }
572        } else {
573            match cmd
574                .stdout(Stdio::inherit())
575                .stderr(Stdio::inherit())
576                .status()
577            {
578                Ok(s) if s.success() => true,
579                Ok(_) => {
580                    eprintln!(
581                        "{program} test: tsc reported errors against {}",
582                        tsconfig.display()
583                    );
584                    return ExitCode::FAILURE;
585                }
586                Err(e) => {
587                    start_failures.push(start_failure(&path, &e));
588                    continue;
589                }
590            }
591        };
592        if tsc_ok {
593            let mut node_cmd = ProcCommand::new(crate::probe::program_path("node"));
594            node_cmd.arg(&main_js);
595            // #854: the coverage path owns the node launch (it sets
596            // `NODE_V8_COVERAGE` and reads the result back), so it does not go
597            // through `finish_runner`.
598            if coverage {
599                return run_with_coverage(
600                    program,
601                    node_cmd,
602                    json,
603                    seed_hex.as_deref(),
604                    case_filter.as_deref(),
605                    &out_js_root,
606                    &output_root,
607                    &input,
608                );
609            }
610            return match finish_runner(
611                node_cmd,
612                json,
613                seed_hex.as_deref(),
614                case_filter.as_deref(),
615                remote.as_ref(),
616            ) {
617                Ok(code) => code,
618                Err(e) => {
619                    if json {
620                        print!(
621                            "{}",
622                            TestRun::runtime_error(format!("could not run node: {e}"), None)
623                                .render()
624                        );
625                    } else {
626                        eprintln!(
627                            "{program} test: tsc succeeded but `node {}` failed: {e}",
628                            main_js.display()
629                        );
630                    }
631                    ExitCode::FAILURE
632                }
633            };
634        }
635    }
636
637    // #854 DECISION C: with `--coverage`, the `tsc → node` path is required — do
638    // not silently fall through to `tsx`, whose on-the-fly transform muddies
639    // which map applies. Fail clearly instead.
640    if coverage {
641        // #1761 review: a `tsc` that is installed but won't start is reported as
642        // such here too, not as missing.
643        if !start_failures.is_empty() {
644            return report_start_failures(program, json, &start_failures, true);
645        }
646        return coverage_unsupported(
647            program,
648            json,
649            "`--coverage` requires `tsc` and `node` on PATH (the CI-shaped path with `.js.map`s); the `tsx` fallback is not supported for coverage.",
650        );
651    }
652
653    // tsx fallback chain.
654    let tsx_runners: Vec<(&str, Vec<&str>)> = vec![("tsx", vec![]), ("npx", vec!["--yes", "tsx"])];
655    for (prog, prefix) in &tsx_runners {
656        let Some(path) = resolve_tool(prog) else {
657            continue;
658        };
659        let mut cmd = ProcCommand::new(&path);
660        for p in prefix {
661            cmd.arg(p);
662        }
663        cmd.arg(&main_ts);
664        match finish_runner(
665            cmd,
666            json,
667            seed_hex.as_deref(),
668            case_filter.as_deref(),
669            remote.as_ref(),
670        ) {
671            Ok(code) => return code,
672            Err(e) => start_failures.push(start_failure(&path, &e)),
673        }
674    }
675
676    if !start_failures.is_empty() {
677        return report_start_failures(program, json, &start_failures, false);
678    }
679
680    if json {
681        print!(
682            "{}",
683            TestRun::runtime_error(
684                "no test runner found: requires `tsc` (with Node.js) or `tsx` on PATH",
685                None
686            )
687            .render()
688        );
689    } else {
690        eprintln!(
691            "{program} test: requires either `tsc` (with Node.js) or `tsx` on PATH. \
692             Install one of:\n{}",
693            install_advice(false)
694        );
695    }
696    ExitCode::FAILURE
697}
698
699/// #1758: the run found runners but none would start. Each failure names the
700/// resolved path and the error. In rich mode the install advice follows, since
701/// installing another runner is often the fix for a broken one. `coverage`
702/// limits that advice to `tsc`, the only runner `--coverage` accepts.
703fn report_start_failures(
704    program: &str,
705    json: bool,
706    failures: &[String],
707    coverage: bool,
708) -> ExitCode {
709    if json {
710        print!(
711            "{}",
712            TestRun::runtime_error("no test runner could be started", Some(failures.join("\n")))
713                .render()
714        );
715    } else {
716        eprintln!("{program} test: no test runner could be started:");
717        for failure in failures {
718            eprintln!("  - {failure}");
719        }
720        eprintln!("Or install another:\n{}", install_advice(coverage));
721    }
722    ExitCode::FAILURE
723}
724
725/// The install advice for a missing or broken runner, one indented line each.
726/// With `coverage`, only `tsc`, since `--coverage` doesn't accept `tsx`.
727fn install_advice(coverage: bool) -> String {
728    let tsc = format!(
729        "  - `npm install -g typescript@{}` (provides tsc, which type-checks; requires Node.js to run output)",
730        bynk_emit::TYPESCRIPT_MAJOR_TESTED
731    );
732    if coverage {
733        return tsc;
734    }
735    format!(
736        "{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."
737    )
738}
739
740/// A normal run with no suites — the JSON-mode document for a project with no
741/// tests, or `--no-run`.
742fn empty_run() -> TestRun {
743    TestRun::empty()
744}
745
746/// v0.67: map the compile's retained test manifest into discovery [`Suite`]s for
747/// the `--no-run --format json` document. Each case is `outcome: "discovered"`,
748/// carrying its declaration `location` (when known) for editor click-through.
749fn discovery_suites(out: &ProjectOutput) -> Vec<Suite> {
750    out.discovered
751        .iter()
752        .map(|s| Suite {
753            name: s.name.clone(),
754            kind: s.kind.to_string(),
755            cases: s
756                .cases
757                .iter()
758                .map(|c| Case {
759                    name: c.name.clone(),
760                    outcome: "discovered".to_string(),
761                    message: None,
762                    location: c.location.as_ref().map(|l| Location {
763                        path: l.path.clone(),
764                        line: l.line,
765                        col: l.col,
766                    }),
767                    retries: None,
768                })
769                .collect(),
770        })
771        .collect()
772}
773
774/// Execute the built runner command and produce its exit code. In JSON mode the
775/// runner's stdout (NDJSON) and stderr are captured, folded into the pinned
776/// document, and printed; otherwise stdio is inherited so the human ✓ / ✗ output
777/// flows straight through. Either way the **exit code follows the runner's own
778/// process status**, so a mid-run crash (a complete NDJSON prefix but no
779/// `run-end`) is never reported as success.
780fn finish_runner(
781    mut cmd: ProcCommand,
782    json: bool,
783    seed_hex: Option<&str>,
784    case: Option<&str>,
785    remote: Option<&crate::remote::RemoteRun>,
786) -> std::io::Result<ExitCode> {
787    if let Some(hex) = seed_hex {
788        cmd.env("BYNK_TEST_SEED", hex);
789    }
790    // #2138: the remote harness reads each Worker's URL from here; the
791    // target's secrets are already in the inherited environment.
792    if let Some(run) = remote {
793        cmd.env("BYNK_TEST_TARGETS", run.targets_json());
794        cmd.env(crate::remote::SECRETS_ENV, run.secrets_json());
795        cmd.env("BYNK_TEST_SETTLE_MS", run.settle_ms.to_string());
796        // #2138: the run's own `run.id`, always set, so a value left in the
797        // environment never makes two runs share one.
798        cmd.env(crate::remote::RUN_ID_ENV, &run.run_id);
799    }
800    if let Some(name) = case {
801        cmd.env("BYNK_TEST_CASE", name);
802    }
803    if json {
804        cmd.env("BYNK_TEST_FORMAT", "ndjson");
805        let out = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output()?;
806        let stdout = String::from_utf8_lossy(&out.stdout);
807        let stderr = String::from_utf8_lossy(&out.stderr);
808        let mut doc = crate::test_json::parse_ndjson(&stdout).into_document(&stderr);
809        if let Some(run) = remote {
810            let skipped: Vec<(String, String, String)> = run
811                .skipped
812                .iter()
813                .map(|(suite, k)| (suite.clone(), k.case.clone(), k.reason.clone()))
814                .collect();
815            doc = doc.with_remote(run.transport(), &skipped);
816        }
817        print!("{}", doc.render());
818        Ok(exit_from(out.status.success()))
819    } else {
820        let status = cmd
821            .stdout(Stdio::inherit())
822            .stderr(Stdio::inherit())
823            .status()?;
824        Ok(exit_from(status.success()))
825    }
826}
827
828fn exit_from(success: bool) -> ExitCode {
829    if success {
830        ExitCode::SUCCESS
831    } else {
832        ExitCode::FAILURE
833    }
834}
835
836/// #854: a `--coverage` request that cannot be honoured (unsupported flag combo,
837/// no `tsc → node`, or a setup error). In JSON mode it is a `runtime` error so
838/// the document stays the only thing on stdout; in rich mode a plain stderr line.
839fn coverage_unsupported(program: &str, json: bool, message: impl Into<String>) -> ExitCode {
840    let message = message.into();
841    if json {
842        print!("{}", TestRun::runtime_error(message, None).render());
843    } else {
844        eprintln!("{program} test --coverage: {message}");
845    }
846    ExitCode::FAILURE
847}
848
849/// #854: run the emitted runner under V8 coverage and attribute the result to
850/// `.bynk` source. Owns the `node` launch: it points `NODE_V8_COVERAGE` at a
851/// scratch dir, runs the suite exactly as [`finish_runner`] would, then remaps
852/// the V8 output through the emitted source maps ([`crate::coverage`]). In rich
853/// mode the summary table is appended after the human ✓ / ✗ output; in JSON mode
854/// the `coverage` block is folded into the pinned document. The **exit code
855/// follows the run's own status** — coverage is a report about the run, never a
856/// gate on it (a partial or unreadable map degrades the numbers, not the code).
857#[allow(clippy::too_many_arguments)]
858fn run_with_coverage(
859    program: &str,
860    mut cmd: ProcCommand,
861    json: bool,
862    seed_hex: Option<&str>,
863    case: Option<&str>,
864    out_js_root: &Path,
865    out_root: &Path,
866    source_root: &Path,
867) -> ExitCode {
868    // A scratch dir beside the build output; cleared first so a prior run's JSON
869    // never leaks in. `NODE_V8_COVERAGE` writes one file per process on exit.
870    let cov_dir = out_root.join(".v8-coverage");
871    let _ = std::fs::remove_dir_all(&cov_dir);
872    if let Err(e) = std::fs::create_dir_all(&cov_dir) {
873        return coverage_unsupported(
874            program,
875            json,
876            format!("could not create the coverage dir: {e}"),
877        );
878    }
879    cmd.env("NODE_V8_COVERAGE", &cov_dir);
880    if let Some(hex) = seed_hex {
881        cmd.env("BYNK_TEST_SEED", hex);
882    }
883    if let Some(name) = case {
884        cmd.env("BYNK_TEST_CASE", name);
885    }
886
887    let collect = || {
888        crate::coverage::collect_coverage(&cov_dir, out_js_root, out_root, source_root)
889            .unwrap_or_default()
890    };
891
892    let code = if json {
893        cmd.env("BYNK_TEST_FORMAT", "ndjson");
894        let out = match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output() {
895            Ok(o) => o,
896            Err(e) => {
897                let _ = std::fs::remove_dir_all(&cov_dir);
898                return coverage_unsupported(program, true, format!("could not run node: {e}"));
899            }
900        };
901        let stdout = String::from_utf8_lossy(&out.stdout);
902        let stderr = String::from_utf8_lossy(&out.stderr);
903        let report = collect();
904        let doc = crate::test_json::parse_ndjson(&stdout)
905            .into_document(&stderr)
906            .with_coverage(&report);
907        print!("{}", doc.render());
908        exit_from(out.status.success())
909    } else {
910        let status = cmd
911            .stdout(Stdio::inherit())
912            .stderr(Stdio::inherit())
913            .status();
914        let status = match status {
915            Ok(s) => s,
916            Err(e) => {
917                let _ = std::fs::remove_dir_all(&cov_dir);
918                eprintln!("{program} test --coverage: could not run node: {e}");
919                return ExitCode::FAILURE;
920            }
921        };
922        let report = collect();
923        print!("{}", crate::coverage::render_rich(&report));
924        exit_from(status.success())
925    };
926    let _ = std::fs::remove_dir_all(&cov_dir);
927    code
928}
929
930/// #2146: mark the tsc output tree as ES modules, so Node runs the ESM that tsc
931/// emits from the `"type": "module"` output tree (#2148).
932fn write_esm_marker(out_js_root: &Path) -> std::io::Result<()> {
933    std::fs::create_dir_all(out_js_root)?;
934    std::fs::write(
935        out_js_root.join("package.json"),
936        "{\n  \"type\": \"module\"\n}\n",
937    )
938}
939
940/// Node's default inspector port.
941const NODE_INSPECT_PORT: u16 = 9229;
942
943/// The `--inspect-brk` flag for `run_inspect` (#2115). Node's default port,
944/// 9229, when it is free, so a debugger that looks there by default (Chrome's
945/// `chrome://inspect`) still finds the runner. When something else holds it
946/// (another inspected process, a stray `workerd`), plain `--inspect-brk` would
947/// print `Starting inspector on 127.0.0.1:9229 failed: address already in use`
948/// and run the tests *without* an inspector, so no debugger could attach. Then
949/// ask for port 0: Node picks a free one and prints it in the `ws://` URL, which
950/// is what attaching clients (the VS Code extension included) read.
951fn inspect_brk_arg(default_port_free: bool) -> &'static str {
952    if default_port_free {
953        "--inspect-brk"
954    } else {
955        "--inspect-brk=127.0.0.1:0"
956    }
957}
958
959/// Slice 2 (ADR 0104): launch the emitted `.ts` test entry under Node's inspector
960/// and hand off. Node prints its inspector `ws://` URL to stderr and pauses at the
961/// first line (`--inspect-brk`) until a JavaScript debugger attaches; breakpoints
962/// set in `.bynk` resolve through the emitted source maps. `--experimental-strip-types`
963/// runs the `.ts` directly under line-preserving type-stripping (Node ≥ 22.6;
964/// unflagged ≥ 23.6) — no `tsc`, so slice 1's `.ts.map` applies to the running file.
965fn run_inspect(
966    program: &str,
967    entry: &Path,
968    seed_hex: Option<&str>,
969    case: Option<&str>,
970) -> ExitCode {
971    let Some(node) = resolve_tool("node") else {
972        eprintln!("{program} test --inspect: `node` was not found on PATH");
973        return ExitCode::FAILURE;
974    };
975    eprintln!("{program} test --inspect: launching the test runner under Node's inspector.");
976    eprintln!("  Attach a JavaScript debugger to the inspector URL below; breakpoints set");
977    eprintln!("  in `.bynk` sources resolve through the emitted source maps.");
978    eprintln!("  (Requires Node \u{2265} 22.6 for TypeScript type-stripping.)");
979    let mut cmd = ProcCommand::new(node);
980    if let Some(hex) = seed_hex {
981        cmd.env("BYNK_TEST_SEED", hex);
982    }
983    if let Some(name) = case {
984        cmd.env("BYNK_TEST_CASE", name);
985    }
986    let default_free = std::net::TcpListener::bind(("127.0.0.1", NODE_INSPECT_PORT)).is_ok();
987    if !default_free {
988        eprintln!(
989            "  Port {NODE_INSPECT_PORT} is in use, so the inspector takes a free port; use the URL below."
990        );
991    }
992    cmd.arg("--experimental-strip-types")
993        .arg(inspect_brk_arg(default_free))
994        .arg(entry);
995    match cmd
996        .stdout(Stdio::inherit())
997        .stderr(Stdio::inherit())
998        .status()
999    {
1000        Ok(s) => exit_from(s.success()),
1001        Err(e) => {
1002            eprintln!("{program} test --inspect: could not run node: {e}");
1003            ExitCode::FAILURE
1004        }
1005    }
1006}
1007
1008#[cfg(test)]
1009mod inspect_port_tests {
1010    use super::inspect_brk_arg;
1011
1012    #[test]
1013    fn uses_the_default_port_when_free_and_a_free_port_otherwise() {
1014        assert_eq!(inspect_brk_arg(true), "--inspect-brk");
1015        assert_eq!(inspect_brk_arg(false), "--inspect-brk=127.0.0.1:0");
1016    }
1017}