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    /// One JSON event per line as each is produced, for a consumer that wants
55    /// results while the run is going (see [`crate::test_stream`]).
56    Ndjson,
57}
58
59impl TestFormat {
60    /// The `--format` token this maps to when `bynk test` shells a resolved
61    /// `bynkc` (`TestFormat::as_str` isn't `ValueEnum`-derived, since the
62    /// wire value and the token clap parses happen to coincide, but the two
63    /// concerns — "what did the user type" vs. "what do I forward" — are
64    /// worth keeping textually distinct call sites for).
65    pub fn as_bynkc_arg(self) -> &'static str {
66        match self {
67            TestFormat::Rich => "rich",
68            TestFormat::Json => "json",
69            TestFormat::Ndjson => "ndjson",
70        }
71    }
72}
73
74/// The `test` subcommand's flags — the one contract `bynkc test` and `bynk
75/// test` both `#[command(flatten)]` (review findings #40/#72/#20/#21
76/// remainder), replacing four independent hand-spellings (`bynkc::cli`,
77/// `bynk::cli`, `bynk::test::TestArgs`, and the argv-literal rebuild that
78/// turned the latter back into flags for the `bynkc` shell-out) with one.
79/// Field docs here are the CLI help text for both commands' flags.
80#[derive(clap::Args, Debug, Clone)]
81pub struct TestArgs {
82    /// Input project root directory. Defaults to the current directory.
83    #[arg(default_value = ".")]
84    pub input: PathBuf,
85    /// Where to write compiled TypeScript test runner modules.
86    /// Defaults to `<input>/out`.
87    #[arg(short, long)]
88    pub output: Option<PathBuf>,
89    /// Skip the runner invocation. With `--format rich` this emits the
90    /// generated test files (for CI flows that drive the runner separately);
91    /// with `--format json` it emits a discovery document listing every
92    /// suite and case (each `outcome: "discovered"`) without running them —
93    /// a pure compile, no `tsc`/Node.
94    #[arg(long)]
95    pub no_run: bool,
96    /// Output format. `rich` (default) is the grouped ✓ / ✗ human output;
97    /// `json` is a single pinned JSON document of results, for tooling;
98    /// `ndjson` is the same results as one JSON event per line, written as
99    /// each suite finishes. `ndjson` cannot be combined with `--coverage` or
100    /// `--no-run`.
101    #[arg(long, value_enum, default_value_t = TestFormat::Rich)]
102    pub format: TestFormat,
103    /// Compile a debug build and launch the test runner under Node's
104    /// inspector (`node --inspect-brk`), printing the inspector URL for a
105    /// JavaScript debugger to attach. The emitted `.ts` runs directly under
106    /// Node's line-preserving type-stripping, so source maps resolve
107    /// breakpoints back to `.bynk`. Requires Node ≥ 22.18 (or ≥ 23.6
108    /// unflagged). Does not run `tsc`.
109    #[arg(long)]
110    pub inspect: bool,
111    /// The root seed for generative `property` tests, as hex (e.g. `0x5f3a`).
112    /// A failing property prints the seed it used; re-running with `--seed
113    /// <hex>` reproduces that run byte-for-byte. Omitted, each run draws a
114    /// fresh random seed.
115    #[arg(long)]
116    pub seed: Option<String>,
117    /// Run only the test cases named `<name>`, skipping the rest — the filter
118    /// behind the editor's per-case `▷ Run Test` lens. Matches by exact case
119    /// name across suites. Repeat the flag to run several cases in one
120    /// process; values of the same flag combine as "or", and with `--suite`
121    /// as "and". A name that selects no case fails the run. Omitted, every
122    /// case runs. No effect with `--no-run` (discovery lists all cases
123    /// regardless).
124    #[arg(long, value_name = "NAME")]
125    pub case: Vec<String>,
126    /// Run only the cases of the suite named `<name>`, as the JSON document
127    /// reports it (`suites[].name`, without an integration suite's prefix);
128    /// any suite kind matches. Repeat the flag to select several suites. A
129    /// suite that is not selected is left out of the output entirely. A name
130    /// that selects no suite fails the run. No effect with `--no-run`.
131    #[arg(long, value_name = "NAME")]
132    pub suite: Vec<String>,
133    /// For a caller that runs one selection as several processes (`bynk test`
134    /// across a workspace's members, `--against dev` across `env` groups): this
135    /// run sees only part of the `--case`/`--suite` selection, so a value it
136    /// selects nothing for is not an error here. The caller judges the values.
137    #[arg(long, hide = true)]
138    pub partial_selection: bool,
139    /// After the suite runs, report statement/line coverage attributed to
140    /// `.bynk` source (a rich summary table, or a `coverage` block in
141    /// `--format json`). Requires the `tsc → node` path: incompatible with
142    /// `--inspect` and `--no-run`, and errors if only `tsx` is available.
143    #[arg(long)]
144    pub coverage: bool,
145    /// Run the `system` suites against a real runtime instead of in process:
146    /// `dev`, a local `bynk dev` the runner starts itself (through `bynk
147    /// test` only), or a target named under `[test.targets]` in `bynk.toml`.
148    /// A bare URL is refused, and so is a target marked `production = true`.
149    /// A named target's secrets are read from the environment. Only the
150    /// `system` suites run, and a case that uses what a real runtime can't
151    /// honour (an `on call` address, `advance Clock`, a cron or queue address,
152    /// an asset fetch) is refused unless `--skip-unsupported` is passed.
153    #[arg(long, value_name = "TARGET")]
154    pub against: Option<String>,
155    /// With `--against`, skip each `system` case a real runtime can't
156    /// honour, naming it and why, instead of refusing the run.
157    #[arg(long, requires = "against")]
158    pub skip_unsupported: bool,
159    /// With `--against`, how long a failing `expect` is retried before it
160    /// fails, for each retried stretch: the addresses since the last unsafe one (anything but `GET`,
161    /// `HEAD` or `OPTIONS`) are re-run before each retry, so a real system's
162    /// latency doesn't fail a case. A duration such as `5s`, `500ms` or `0`
163    /// (no retry).
164    #[arg(
165        long,
166        value_name = "DURATION",
167        requires = "against",
168        default_value = "5s"
169    )]
170    pub settle: Option<String>,
171}
172
173/// Where `test` writes its output: `-o`, or `<input>/out`.
174fn output_root_for(input: &Path, output: Option<&Path>) -> PathBuf {
175    output.map_or_else(|| input.join("out"), Path::to_path_buf)
176}
177
178/// Normalise a `--seed` value (`0x5f3a` or `5f3a`) to the bare-hex form the
179/// runner reads from `BYNK_TEST_SEED` (JS `parseInt(_, 16)` does not accept a
180/// `0x` prefix). Returns `None` for a non-hex value, so a typo is ignored rather
181/// than silently seeding to zero.
182fn normalise_seed(raw: &str) -> Option<String> {
183    let hex = raw
184        .strip_prefix("0x")
185        .or_else(|| raw.strip_prefix("0X"))
186        .unwrap_or(raw);
187    if hex.is_empty() || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
188        return None;
189    }
190    Some(hex.to_string())
191}
192
193/// In `--format json` mode the deterministic surface is the document on stdout,
194/// so a `<program> test:` line on stderr is fine but must never reach stdout.
195/// `program` prefixes stderr messages (`"bynkc"` or `"bynk"`) so they read the
196/// same as before the move.
197pub fn run_test(program: &str, args: TestArgs) -> ExitCode {
198    let TestArgs {
199        input,
200        output,
201        no_run,
202        format,
203        inspect,
204        seed,
205        case,
206        suite,
207        partial_selection,
208        coverage,
209        against,
210        skip_unsupported,
211        settle,
212    } = args;
213    // `json` is "machine-readable": the document or the stream. `stream` says
214    // which; everything that prints a document goes through `print_run`.
215    let stream = format == TestFormat::Ndjson;
216    let json = format == TestFormat::Json || stream;
217    // #2267 (decision D): coverage is a whole-run aggregate and discovery is a
218    // document, so neither is a stream; `--format json` serves both.
219    if stream && (coverage || no_run) {
220        let message = if coverage {
221            "`--format ndjson` cannot be combined with `--coverage`: coverage is a whole-run report, so use `--format json`."
222        } else {
223            "`--format ndjson` cannot be combined with `--no-run`: discovery is a document, so use `--format json`."
224        };
225        crate::test_stream::print_run(true, &TestRun::runtime_error(message, None));
226        return ExitCode::FAILURE;
227    }
228    // #2138: `--against` — resolve and check the target before anything is
229    // built, so a refused run makes no request. `dev` is the `bynk` driver's
230    // to start (`bynk test --against dev`); here it is refused.
231    let mut remote = match against.as_deref() {
232        None => None,
233        Some(value) => {
234            let refused = |message: String| {
235                if json {
236                    crate::test_stream::print_run(stream, &TestRun::runtime_error(message, None));
237                } else {
238                    eprintln!("{program} test: {message}");
239                }
240                ExitCode::FAILURE
241            };
242            let settle_ms = match settle.as_deref().map(crate::remote::parse_settle) {
243                None => crate::remote::DEFAULT_SETTLE_MS,
244                Some(Ok(ms)) => ms,
245                Some(Err(e)) => return refused(e),
246            };
247            if inspect || coverage {
248                return refused(
249                    "`--against` cannot be combined with `--inspect` or `--coverage`, which observe the in-process run".to_string(),
250                );
251            }
252            match crate::remote::resolve(
253                value,
254                &input,
255                |name| std::env::var(name).ok(),
256                "starts a local `bynk dev`, which only the `bynk` driver can do: run `bynk test --against dev`",
257            ) {
258                Ok(crate::remote::Against::Run(run)) => {
259                    Some(crate::remote::RemoteRun { settle_ms, ..run })
260                }
261                Ok(crate::remote::Against::StartDev(bynk)) => {
262                    return crate::dev_run::run(
263                        program,
264                        &crate::dev_run::DevRunArgs {
265                            input: input.clone(),
266                            output: output_root_for(&input, output.as_deref()),
267                            json,
268                            stream,
269                            seed: seed.clone(),
270                            filter: crate::test_filter::TestFilter::new(&suite, &case),
271                            skip_unsupported,
272                            settle: settle.clone(),
273                            run_id: crate::remote::fresh_run_id(),
274                            bynk,
275                        },
276                    );
277                }
278                Err(refusal) => return refused(refusal.to_string()),
279            }
280        }
281    };
282    // #854 DECISION C: `--coverage` requires the `tsc → node` path — the CI-shaped
283    // path with real `.js.map`s. `--inspect` is a debug path with no `tsc` (and a
284    // different map role), and `--no-run` never launches a process to observe.
285    // Reject both up front with an actionable message rather than producing
286    // silently-wrong or empty numbers.
287    if coverage && inspect {
288        return coverage_unsupported(
289            program,
290            json,
291            "`--coverage` cannot be combined with `--inspect` — coverage needs the `tsc → node` run, not the inspector.",
292        );
293    }
294    if coverage && no_run {
295        return coverage_unsupported(
296            program,
297            json,
298            "`--coverage` cannot be combined with `--no-run` — there is no run to measure.",
299        );
300    }
301    // v0.127 (editor-currency slice 6): the per-case run filter, #2273: now a set
302    // of cases and suites. An empty value is dropped, so an empty `--case` is
303    // still unset (run all) rather than "match the empty name".
304    let filter = crate::test_filter::TestFilter::new(&suite, &case).partial(partial_selection);
305    // v0.114: the root seed for generative `property` tests, threaded to the
306    // runner via `BYNK_TEST_SEED` (bare hex). An unparseable value is dropped
307    // with a warning so a run still proceeds with a fresh seed.
308    let seed_hex = match seed.as_deref() {
309        Some(raw) => match normalise_seed(raw) {
310            Some(hex) => Some(hex),
311            None => {
312                if !json {
313                    eprintln!(
314                        "{program} test: ignoring --seed `{raw}` (not a hex value like 0x5f3a)"
315                    );
316                }
317                None
318            }
319        },
320        None => None,
321    };
322    let output_root = output_root_for(&input, output.as_deref());
323    if !input.is_dir() {
324        eprintln!(
325            "{program} test: input `{}` must be a project directory containing `.bynk` files",
326            input.display()
327        );
328        return ExitCode::FAILURE;
329    }
330    // v0.9.1: rooting strategy (#46: shared with check/compile via
331    // `project_options`) — a `bynk.toml` or `src/` subdir selects split-paths
332    // mode (sources under `[paths] src`, tests under `[paths] tests`); else the
333    // legacy single-tree where `<input>` is both the source and tests root.
334    // `--inspect` compiles a debug build: `.ts` import specifiers so the emitted
335    // entry runs directly under Node's strip-only type-stripping (slice 2), where
336    // slice 1's source maps apply unchanged. A normal run keeps `.js` specifiers
337    // for the `tsc → node` path.
338    let options = {
339        // v0.115: `test` compiles the dev/test profile — the function
340        // contract call-site guard is emitted (DECISION J). `compile`
341        // leaves it off, so contract checks never reach production.
342        // #2078: reconcile events against the committed `bynk.schema.lock`,
343        // read-only — a non-additive change fails `test` as it fails a
344        // build, and `out.schema_lock` is never written back.
345        let o = match crate::try_project_options(&input)
346            .map_err(|e| e.to_string())
347            .and_then(|o| Ok(o.schema_registry(crate::schema_lock::read_only(&input)?)))
348        {
349            Ok(o) => o.contracts(true),
350            Err(e) => {
351                if json {
352                    crate::test_stream::print_run(stream, &TestRun::runtime_error(e, None));
353                } else {
354                    eprintln!("{program} test: {e}");
355                }
356                return ExitCode::FAILURE;
357            }
358        };
359        // #2138: a remote run emits only the `system` suites, driven at
360        // the target's URLs.
361        let o = o.remote_tests(remote.as_ref().map(|run| bynk_emit::project::RemoteTests {
362            skip_unsupported,
363            vars: run.vars.clone(),
364            secrets: run.secrets.clone(),
365            dev: run.dev_env.is_some(),
366            only_env: run.dev_env.clone(),
367        }));
368        if inspect {
369            o.import_ext(ImportExt::Ts)
370        } else {
371            o
372        }
373    };
374    let out = match bynk_emit::project::compile_project(&options) {
375        Ok(out) => out,
376        Err(failure) => {
377            if json {
378                crate::test_stream::print_run(
379                    stream,
380                    &TestRun::compile_error(crate::project_failure_short_lines(&failure)),
381                );
382            } else {
383                crate::print_project_failure(&failure);
384            }
385            return ExitCode::FAILURE;
386        }
387    };
388    // #2461: a fenced `bynk` example in a doc comment is checked as a `case` in
389    // a synthetic suite on its unit. Only once the project itself compiles, so
390    // an example is never blamed for an error elsewhere; a failing example
391    // fails the run before anything is emitted or run. An example marked
392    // `bynk test` is then compiled in and run like any other case.
393    let mut out = out;
394    let mut doc_aliases: Vec<(String, String)> = Vec::new();
395    // A remote run builds only the `system` suites, so it has no doc examples.
396    if remote.is_none()
397        && let Some(sources) = &options.sources
398    {
399        let docs = crate::doc_examples::DocExamples::collect(sources);
400        if !docs.is_empty() {
401            let mut errors = Vec::new();
402            let mut checked = None;
403            if docs.has_base() {
404                let check = bynk_emit::project::check_project(
405                    &options.clone().sources(docs.check_overlay(sources)),
406                );
407                errors.extend(docs.remap(check.errors));
408                checked = Some((check.snapshots, check.display_root));
409            }
410            // A declaration fence in a context is checked in a copy of it,
411            // one project check per layer of copies.
412            for layer in 0..docs.layer_count() {
413                let check = bynk_emit::project::check_project(
414                    &options.clone().sources(docs.layer_overlay(layer, sources)),
415                );
416                errors.extend(docs.remap_layer(layer, check.errors));
417                checked = Some((check.snapshots, check.display_root));
418            }
419            let (snapshots, display_root) = checked.expect("a check ran");
420            if !errors.is_empty() {
421                let failure = bynk_emit::project::ProjectFailure {
422                    errors,
423                    snapshots,
424                    display_root,
425                };
426                if json {
427                    crate::test_stream::print_run(
428                        stream,
429                        &TestRun::compile_error(crate::project_failure_short_lines(&failure)),
430                    );
431                } else {
432                    crate::print_project_failure(&failure);
433                }
434                return ExitCode::FAILURE;
435            }
436            if docs.has_runnable() {
437                out = match bynk_emit::project::compile_project(
438                    &options.clone().sources(docs.run_overlay(sources)),
439                ) {
440                    Ok(out) => out,
441                    Err(failure) => {
442                        if json {
443                            crate::test_stream::print_run(
444                                stream,
445                                &TestRun::compile_error(crate::project_failure_short_lines(
446                                    &failure,
447                                )),
448                            );
449                        } else {
450                            crate::print_project_failure(&failure);
451                        }
452                        return ExitCode::FAILURE;
453                    }
454                };
455                doc_aliases = docs.run_aliases();
456                // A doc test's location is the real file's, line and column.
457                for case in out.discovered.iter_mut().flat_map(|s| s.cases.iter_mut()) {
458                    if let Some(loc) = case.location.as_mut() {
459                        // The synthetic file is where a doc example's case lives.
460                        case.doc = crate::doc_examples::is_synthetic_path(&loc.path, &doc_aliases);
461                        loc.path = crate::doc_examples::apply_aliases(&loc.path, &doc_aliases)
462                            .into_owned();
463                    }
464                }
465            }
466        }
467    }
468    // v0.67: `--no-run --format json` is pure discovery — render the suite/case
469    // manifest the compile retained and stop. No TS is written, no `tsc`/`node`
470    // runs, and the integration workers re-compile below is skipped (the manifest
471    // already carries integration suites from the compile above). A compile
472    // failure took the `compile`-error path above, exactly as a run would.
473    if no_run && json {
474        crate::test_stream::print_run(stream, &TestRun::discovered(discovery_suites(&out)));
475        return ExitCode::SUCCESS;
476    }
477    // #2138: the cases the remote build skipped, and the target, named before
478    // the first request.
479    if let Some(run) = remote.as_mut() {
480        run.skipped = out
481            .discovered
482            .iter()
483            .flat_map(|s| s.skipped.iter().map(|k| (s.name.clone(), k.clone())))
484            .collect();
485        eprint!("{program} test: {}", run.banner());
486        if !json {
487            for (suite, skip) in run
488                .skipped
489                .iter()
490                .filter(|(suite, skip)| filter.selects(suite, &skip.case))
491            {
492                eprintln!(
493                    "{program} test: skipped `{suite}` case \"{}\" [{}]: {}",
494                    skip.case, skip.code, skip.reason
495                );
496            }
497        }
498    }
499
500    // Write every artefact to disk under the output root.
501    let mut wrote_any_test = false;
502    let mut has_integration = false;
503    for (path, doc) in &out.artefacts.docs {
504        // Map-aware write (slice 2): carries the `.ts.map` siblings + trailers so
505        // a debug run (`--inspect`) can resolve `.bynk` breakpoints. Harmless for a
506        // normal run, which transpiles via `tsc` and ignores the trailer.
507        if let Err(e) = crate::write_document(path, doc, &out.artefacts.docs, &output_root) {
508            eprintln!(
509                "{program} test: could not write `{}`: {e}",
510                output_root.join(path).display()
511            );
512            return ExitCode::FAILURE;
513        }
514        let rel = path.to_string_lossy();
515        if rel.starts_with("tests/") {
516            wrote_any_test = true;
517        }
518        if rel.starts_with("tests/integration_") {
519            has_integration = true;
520        }
521    }
522
523    // v0.16: integration tests stand their participants up as real Workers, so
524    // they import the workers-mode output (`workers/**`) and the serialise/
525    // deserialise helpers the workers commons emit. The bundle compile above
526    // does not produce those, so run a second compile in workers mode and
527    // overlay everything except the `tests/` tree (whose unit modules import
528    // the bundle output). The workers commons are a strict superset of the
529    // bundle ones, so overwriting them is safe for the bundle code too.
530    let mut workers_modules = None;
531    if has_integration {
532        // v0.115/slice 2: reuse `options` (not a fresh `project_options(&input)`)
533        // so this second compile keeps `contracts(true)` and, under `--inspect`,
534        // `import_ext(Ts)` — a from-scratch rebuild silently dropped both.
535        let workers_out =
536            bynk_emit::project::compile_project(&options.clone().target(BuildTarget::Workers));
537        let workers_out = match workers_out {
538            Ok(o) => o,
539            Err(failure) => {
540                if json {
541                    crate::test_stream::print_run(
542                        stream,
543                        &TestRun::compile_error(crate::project_failure_short_lines(&failure)),
544                    );
545                } else {
546                    crate::print_project_failure(&failure);
547                }
548                return ExitCode::FAILURE;
549            }
550        };
551        for (path, doc) in &workers_out.artefacts.docs {
552            if path.to_string_lossy().starts_with("tests/") {
553                continue;
554            }
555            if let Err(e) =
556                crate::write_document(path, doc, &workers_out.artefacts.docs, &output_root)
557            {
558                eprintln!(
559                    "{program} test: could not write `{}`: {e}",
560                    output_root.join(path).display()
561                );
562                return ExitCode::FAILURE;
563            }
564        }
565        workers_modules = Some(workers_out.artefacts.modules.clone());
566    }
567
568    // #2321: the WebAssembly modules adapter bindings include, with the Node form
569    // of each loader (after every document write, which restores the Workers
570    // form).
571    for modules in [Some(&out.artefacts.modules), workers_modules.as_ref()]
572        .into_iter()
573        .flatten()
574    {
575        if let Err(e) = crate::write_modules_for_node(modules, &output_root) {
576            eprintln!("{program} test: could not write a WebAssembly module: {e}");
577            return ExitCode::FAILURE;
578        }
579    }
580
581    if !wrote_any_test {
582        if json {
583            crate::test_stream::print_run(stream, &empty_run());
584        } else {
585            eprintln!(
586                "{program} test: no test declarations found in `{}`",
587                input.display()
588            );
589        }
590        return ExitCode::SUCCESS;
591    }
592
593    let main_ts = output_root.join("tests").join("main.ts");
594    if no_run {
595        // Rich `--no-run` is the CI emit helper: write the runner modules and
596        // report where they landed. (JSON `--no-run` already returned above with
597        // the discovery document — it never reaches here.)
598        eprintln!("{program} test: tests emitted to {}", main_ts.display());
599        return ExitCode::SUCCESS;
600    }
601
602    // Slice 2 (ADR 0104): launch the emitted `.ts` test entry directly under
603    // Node's inspector. No `tsc` — the `.ts` runs under line-preserving
604    // type-stripping, so the source maps written above resolve `.bynk`
605    // breakpoints. Node prints its inspector URL; a debugger attaches there.
606    if inspect {
607        return run_inspect(program, &main_ts, seed_hex.as_deref(), &filter);
608    }
609
610    let tsconfig = output_root.join("tsconfig.json");
611    // #854: coverage needs tsc's `.js.map`s (remap hop 1). Overwrite the default
612    // tsconfig the compile wrote with the `sourceMap: true` variant, kept
613    // coverage-only so a normal test run / deployment build ships no maps.
614    if coverage
615        && let Err(e) = std::fs::write(
616            &tsconfig,
617            bynk_emit::emitter::emit_tsconfig_with_source_maps(),
618        )
619    {
620        return coverage_unsupported(
621            program,
622            json,
623            format!("could not enable source maps for coverage: {e}"),
624        );
625    }
626    // Preferred: `tsc -p out/tsconfig.json` → `node out-js/tests/main.js`.
627    // tsc gives us full type-checking before execution and matches what a
628    // production deployment build would do. If tsc is missing, fall back to
629    // tsx, which compiles-and-runs in one step. We also try npx-mediated
630    // variants so a developer with `npm` available doesn't need a global
631    // install. If nothing works, emit an actionable error message.
632    let out_js_root = output_root
633        .parent()
634        .map(|p| p.join("out-js"))
635        .unwrap_or_else(|| PathBuf::from("out-js"));
636    let main_js = out_js_root.join("tests").join("main.js");
637
638    // #2146: tsc emits ES modules (the compile writes `out/package.json`
639    // declaring `"type": "module"`, which the emitted tsconfig's `module:
640    // NodeNext` follows, #2148), but `out-js` sits in no such `package.json`
641    // scope, so mark it ESM for Node to run the result as such. The `out/`
642    // tree is left as the compiler wrote it.
643    if let Err(e) = write_esm_marker(&out_js_root) {
644        let msg = format!(
645            "could not write {}: {e}",
646            out_js_root.join("package.json").display()
647        );
648        if json {
649            crate::test_stream::print_run(stream, &TestRun::runtime_error(msg, None));
650        } else {
651            eprintln!("{program} test: {msg}");
652        }
653        return ExitCode::FAILURE;
654    }
655
656    // Try a sequence of (program, prefix args) tsc invocations. In JSON mode the
657    // tsc step is captured so its output never reaches stdout (the document is
658    // the only thing on stdout); a tsc failure on the emitted TS is a
659    // toolchain/internal problem, surfaced as a `runtime` error.
660    // #1672: the `npx` fallback provisions the TypeScript major the output is
661    // verified against and users get, not an older pin.
662    let typescript_pkg = format!("typescript@{}", bynk_emit::TYPESCRIPT_MAJOR_TESTED);
663    let mut start_failures: Vec<String> = Vec::new();
664    let tsc_runners: Vec<(&str, Vec<&str>)> = vec![
665        ("tsc", vec![]),
666        ("npx", vec!["--yes", "-p", typescript_pkg.as_str(), "tsc"]),
667    ];
668    for (prog, prefix) in &tsc_runners {
669        let Some(path) = resolve_tool(prog) else {
670            continue;
671        };
672        let mut cmd = ProcCommand::new(&path);
673        for p in prefix {
674            cmd.arg(p);
675        }
676        cmd.arg("-p").arg(&tsconfig);
677        let tsc_ok = if json {
678            match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output() {
679                Ok(out) if out.status.success() => true,
680                Ok(out) => {
681                    // tsc writes its diagnostics to *stdout*; capturing only
682                    // stderr left the document's most useful field empty.
683                    let mut detail = String::from_utf8_lossy(&out.stdout).into_owned();
684                    let err_text = String::from_utf8_lossy(&out.stderr);
685                    if !err_text.trim().is_empty() {
686                        if !detail.is_empty() {
687                            detail.push('\n');
688                        }
689                        detail.push_str(&err_text);
690                    }
691                    crate::test_stream::print_run(
692                        stream,
693                        &TestRun::runtime_error(
694                            "tsc rejected the generated TypeScript",
695                            Some(detail),
696                        ),
697                    );
698                    return ExitCode::FAILURE;
699                }
700                Err(e) => {
701                    start_failures.push(start_failure(&path, &e));
702                    continue;
703                }
704            }
705        } else {
706            match cmd
707                .stdout(Stdio::inherit())
708                .stderr(Stdio::inherit())
709                .status()
710            {
711                Ok(s) if s.success() => true,
712                Ok(_) => {
713                    eprintln!(
714                        "{program} test: tsc reported errors against {}",
715                        tsconfig.display()
716                    );
717                    return ExitCode::FAILURE;
718                }
719                Err(e) => {
720                    start_failures.push(start_failure(&path, &e));
721                    continue;
722                }
723            }
724        };
725        if tsc_ok {
726            let mut node_cmd = ProcCommand::new(crate::probe::program_path("node"));
727            node_cmd.arg(&main_js);
728            // #854: the coverage path owns the node launch (it sets
729            // `NODE_V8_COVERAGE` and reads the result back), so it does not go
730            // through `finish_runner`.
731            if coverage {
732                return run_with_coverage(
733                    program,
734                    node_cmd,
735                    json,
736                    seed_hex.as_deref(),
737                    &filter,
738                    &out_js_root,
739                    &output_root,
740                    &input,
741                    &out.discovered,
742                );
743            }
744            return match finish_runner(
745                program,
746                node_cmd,
747                format,
748                seed_hex.as_deref(),
749                &filter,
750                remote.as_ref(),
751                &out.discovered,
752                &doc_aliases,
753            ) {
754                Ok(code) => code,
755                Err(e) => {
756                    if json {
757                        crate::test_stream::print_run(
758                            stream,
759                            &TestRun::runtime_error(format!("could not run node: {e}"), None),
760                        );
761                    } else {
762                        eprintln!(
763                            "{program} test: tsc succeeded but `node {}` failed: {e}",
764                            main_js.display()
765                        );
766                    }
767                    ExitCode::FAILURE
768                }
769            };
770        }
771    }
772
773    // #854 DECISION C: with `--coverage`, the `tsc → node` path is required — do
774    // not silently fall through to `tsx`, whose on-the-fly transform muddies
775    // which map applies. Fail clearly instead.
776    if coverage {
777        // #1761 review: a `tsc` that is installed but won't start is reported as
778        // such here too, not as missing.
779        if !start_failures.is_empty() {
780            return report_start_failures(program, json, stream, &start_failures, true);
781        }
782        return coverage_unsupported(
783            program,
784            json,
785            "`--coverage` requires `tsc` and `node` on PATH (the CI-shaped path with `.js.map`s); the `tsx` fallback is not supported for coverage.",
786        );
787    }
788
789    // tsx fallback chain.
790    let tsx_runners: Vec<(&str, Vec<&str>)> = vec![("tsx", vec![]), ("npx", vec!["--yes", "tsx"])];
791    for (prog, prefix) in &tsx_runners {
792        let Some(path) = resolve_tool(prog) else {
793            continue;
794        };
795        let mut cmd = ProcCommand::new(&path);
796        for p in prefix {
797            cmd.arg(p);
798        }
799        cmd.arg(&main_ts);
800        match finish_runner(
801            program,
802            cmd,
803            format,
804            seed_hex.as_deref(),
805            &filter,
806            remote.as_ref(),
807            &out.discovered,
808            &doc_aliases,
809        ) {
810            Ok(code) => return code,
811            Err(e) => start_failures.push(start_failure(&path, &e)),
812        }
813    }
814
815    if !start_failures.is_empty() {
816        return report_start_failures(program, json, stream, &start_failures, false);
817    }
818
819    if json {
820        crate::test_stream::print_run(
821            stream,
822            &TestRun::runtime_error(
823                "no test runner found: requires `tsc` (with Node.js) or `tsx` on PATH",
824                None,
825            ),
826        );
827    } else {
828        eprintln!(
829            "{program} test: requires either `tsc` (with Node.js) or `tsx` on PATH. \
830             Install one of:\n{}",
831            install_advice(false)
832        );
833    }
834    ExitCode::FAILURE
835}
836
837/// #1758: the run found runners but none would start. Each failure names the
838/// resolved path and the error. In rich mode the install advice follows, since
839/// installing another runner is often the fix for a broken one. `coverage`
840/// limits that advice to `tsc`, the only runner `--coverage` accepts.
841fn report_start_failures(
842    program: &str,
843    json: bool,
844    stream: bool,
845    failures: &[String],
846    coverage: bool,
847) -> ExitCode {
848    if json {
849        crate::test_stream::print_run(
850            stream,
851            &TestRun::runtime_error("no test runner could be started", Some(failures.join("\n"))),
852        );
853    } else {
854        eprintln!("{program} test: no test runner could be started:");
855        for failure in failures {
856            eprintln!("  - {failure}");
857        }
858        eprintln!("Or install another:\n{}", install_advice(coverage));
859    }
860    ExitCode::FAILURE
861}
862
863/// The install advice for a missing or broken runner, one indented line each.
864/// With `coverage`, only `tsc`, since `--coverage` doesn't accept `tsx`.
865fn install_advice(coverage: bool) -> String {
866    let tsc = format!(
867        "  - `npm install -g typescript@{}` (provides tsc, which type-checks; requires Node.js to run output)",
868        bynk_emit::TYPESCRIPT_MAJOR_TESTED
869    );
870    if coverage {
871        return tsc;
872    }
873    format!(
874        "{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."
875    )
876}
877
878/// A normal run with no suites — the JSON-mode document for a project with no
879/// tests, or `--no-run`.
880fn empty_run() -> TestRun {
881    TestRun::empty()
882}
883
884/// v0.67: map the compile's retained test manifest into discovery [`Suite`]s for
885/// the `--no-run --format json` document. Each case is `outcome: "discovered"`,
886/// carrying its declaration `location` (when known) for editor click-through.
887fn discovery_suites(out: &ProjectOutput) -> Vec<Suite> {
888    out.discovered
889        .iter()
890        .map(|s| Suite {
891            name: s.name.clone(),
892            kind: s.kind.to_string(),
893            cases: s
894                .cases
895                .iter()
896                .map(|c| Case {
897                    name: c.name.clone(),
898                    outcome: "discovered".to_string(),
899                    message: None,
900                    location: c.location.as_ref().map(|l| Location {
901                        path: l.path.clone(),
902                        line: l.line,
903                        col: l.col,
904                    }),
905                    retries: None,
906                    tier: c.tier.map(str::to_string),
907                    kind: c.generative.map(str::to_string),
908                    seed: None,
909                    counterexample: None,
910                    violation: None,
911                    refusals: None,
912                    origin: c.doc.then(|| "doc".to_string()),
913                    duration_ms: None,
914                })
915                .collect(),
916        })
917        .collect()
918}
919
920/// Execute the built runner command and produce its exit code. In JSON mode the
921/// runner's stdout (NDJSON) and stderr are captured, folded into the pinned
922/// document, and printed; in NDJSON mode each event is forwarded as the runner
923/// writes it ([`crate::test_stream`]); otherwise stdio is inherited so the human
924/// ✓ / ✗ output flows straight through. Either way the **exit code follows the runner's own
925/// process status**, so a mid-run crash (a complete NDJSON prefix but no
926/// `run-end`) is never reported as success.
927#[allow(clippy::too_many_arguments)]
928fn finish_runner(
929    program: &str,
930    mut cmd: ProcCommand,
931    format: TestFormat,
932    seed_hex: Option<&str>,
933    filter: &crate::test_filter::TestFilter,
934    remote: Option<&crate::remote::RemoteRun>,
935    discovered: &[bynk_emit::project::DiscoveredSuite],
936    aliases: &[(String, String)],
937) -> std::io::Result<ExitCode> {
938    if let Some(hex) = seed_hex {
939        cmd.env("BYNK_TEST_SEED", hex);
940    }
941    // #2138: the remote harness reads each Worker's URL from here; the
942    // target's secrets are already in the inherited environment.
943    if let Some(run) = remote {
944        cmd.env("BYNK_TEST_TARGETS", run.targets_json());
945        cmd.env(crate::remote::SECRETS_ENV, run.secrets_json());
946        cmd.env("BYNK_TEST_SETTLE_MS", run.settle_ms.to_string());
947        // #2138: the run's own `run.id`, always set, so a value left in the
948        // environment never makes two runs share one.
949        cmd.env(crate::remote::RUN_ID_ENV, &run.run_id);
950    }
951    // The skipped cases the filter selects are not in the build, so the runner
952    // is told they exist, and the document lists only those.
953    let selected_skips: Vec<&(String, bynk_emit::project::RemoteSkip)> = remote
954        .map(|run| {
955            run.skipped
956                .iter()
957                .filter(|(suite, skip)| filter.selects(suite, &skip.case))
958                .collect()
959        })
960        .unwrap_or_default();
961    filter
962        .clone()
963        .with_skipped(
964            selected_skips
965                .iter()
966                .map(|(suite, skip)| (suite.as_str(), skip.case.as_str())),
967        )
968        .apply(program, &mut cmd);
969    if format == TestFormat::Ndjson {
970        // #2267: forward each event as the runner writes it.
971        cmd.env("BYNK_TEST_FORMAT", "ndjson");
972        let mut events = crate::test_stream::Stream::new(crate::test_stream::StreamContext {
973            discovered,
974            skipped: selected_skips
975                .iter()
976                .map(|(suite, k)| (suite.clone(), k.case.clone(), k.reason.clone()))
977                .collect(),
978            transport: remote.map(crate::remote::RemoteRun::transport),
979        });
980        let status =
981            crate::test_stream::forward(cmd, &mut events, &mut std::io::stdout().lock(), aliases)?;
982        events.unmatched().report(program);
983        return Ok(exit_from(status.success()));
984    }
985    if format == TestFormat::Json {
986        cmd.env("BYNK_TEST_FORMAT", "ndjson");
987        let out = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output()?;
988        let stdout = String::from_utf8_lossy(&out.stdout);
989        let stderr = String::from_utf8_lossy(&out.stderr);
990        let stdout = crate::doc_examples::apply_aliases(&stdout, aliases);
991        let stderr = crate::doc_examples::apply_aliases(&stderr, aliases);
992        let parsed = crate::test_json::parse_ndjson(&stdout);
993        let unmatched = parsed.unmatched.clone();
994        let mut doc = parsed.into_document(&stderr).with_tiers(discovered);
995        if let Some(run) = remote {
996            let skipped: Vec<(String, String, String)> = selected_skips
997                .iter()
998                .map(|(suite, k)| (suite.clone(), k.case.clone(), k.reason.clone()))
999                .collect();
1000            doc = doc.with_remote(run.transport(), &skipped);
1001        }
1002        print!("{}", doc.render());
1003        unmatched.report(program);
1004        Ok(exit_from(out.status.success()))
1005    } else if aliases.is_empty() {
1006        let status = cmd
1007            .stdout(Stdio::inherit())
1008            .stderr(Stdio::inherit())
1009            .status()?;
1010        Ok(exit_from(status.success()))
1011    } else {
1012        // #2461: a doc test's output names the real file, not the synthetic
1013        // one it was compiled from, so the lines are rewritten on the way.
1014        Ok(exit_from(run_aliased(cmd, aliases)?.success()))
1015    }
1016}
1017
1018/// Run `cmd`, forwarding its output line by line with the synthetic file names
1019/// in `aliases` replaced by the real ones.
1020fn run_aliased(
1021    mut cmd: ProcCommand,
1022    aliases: &[(String, String)],
1023) -> std::io::Result<std::process::ExitStatus> {
1024    use std::io::{BufRead, BufReader, Write};
1025    // Reads raw bytes a line at a time (as `test_stream::forward` does), so a
1026    // line that is not UTF-8 is passed on lossily and never ends the reading: a
1027    // pipe nobody reads fills and stalls the child. A final line with no newline
1028    // keeps having none.
1029    fn forward(pipe: impl std::io::Read, aliases: &[(String, String)], mut out: impl Write) {
1030        let mut reader = BufReader::new(pipe);
1031        let mut buffer = Vec::new();
1032        loop {
1033            buffer.clear();
1034            match reader.read_until(b'\n', &mut buffer) {
1035                Ok(0) | Err(_) => break,
1036                Ok(_) => {}
1037            }
1038            let line = String::from_utf8_lossy(&buffer);
1039            let line = crate::doc_examples::apply_aliases(&line, aliases);
1040            let _ = out.write_all(line.as_bytes());
1041            let _ = out.flush();
1042        }
1043    }
1044    let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
1045    let stderr_pipe = child.stderr.take();
1046    let err_aliases = aliases.to_vec();
1047    let errors = std::thread::spawn(move || {
1048        if let Some(pipe) = stderr_pipe {
1049            forward(pipe, &err_aliases, std::io::stderr().lock());
1050        }
1051    });
1052    if let Some(pipe) = child.stdout.take() {
1053        forward(pipe, aliases, std::io::stdout().lock());
1054    }
1055    let status = child.wait()?;
1056    let _ = errors.join();
1057    Ok(status)
1058}
1059
1060fn exit_from(success: bool) -> ExitCode {
1061    if success {
1062        ExitCode::SUCCESS
1063    } else {
1064        ExitCode::FAILURE
1065    }
1066}
1067
1068/// #854: a `--coverage` request that cannot be honoured (unsupported flag combo,
1069/// no `tsc → node`, or a setup error). In JSON mode it is a `runtime` error so
1070/// the document stays the only thing on stdout; in rich mode a plain stderr line.
1071fn coverage_unsupported(program: &str, json: bool, message: impl Into<String>) -> ExitCode {
1072    let message = message.into();
1073    if json {
1074        // `--format ndjson` refuses `--coverage` before this is reachable.
1075        crate::test_stream::print_run(false, &TestRun::runtime_error(message, None));
1076    } else {
1077        eprintln!("{program} test --coverage: {message}");
1078    }
1079    ExitCode::FAILURE
1080}
1081
1082/// #854: run the emitted runner under V8 coverage and attribute the result to
1083/// `.bynk` source. Owns the `node` launch: it points `NODE_V8_COVERAGE` at a
1084/// scratch dir, runs the suite exactly as [`finish_runner`] would, then remaps
1085/// the V8 output through the emitted source maps ([`crate::coverage`]). In rich
1086/// mode the summary table is appended after the human ✓ / ✗ output; in JSON mode
1087/// the `coverage` block is folded into the pinned document. The **exit code
1088/// follows the run's own status** — coverage is a report about the run, never a
1089/// gate on it (a partial or unreadable map degrades the numbers, not the code).
1090#[allow(clippy::too_many_arguments)]
1091fn run_with_coverage(
1092    program: &str,
1093    mut cmd: ProcCommand,
1094    json: bool,
1095    seed_hex: Option<&str>,
1096    filter: &crate::test_filter::TestFilter,
1097    out_js_root: &Path,
1098    out_root: &Path,
1099    source_root: &Path,
1100    discovered: &[bynk_emit::project::DiscoveredSuite],
1101) -> ExitCode {
1102    // A scratch dir beside the build output; cleared first so a prior run's JSON
1103    // never leaks in. `NODE_V8_COVERAGE` writes one file per process on exit.
1104    let cov_dir = out_root.join(".v8-coverage");
1105    let _ = std::fs::remove_dir_all(&cov_dir);
1106    if let Err(e) = std::fs::create_dir_all(&cov_dir) {
1107        return coverage_unsupported(
1108            program,
1109            json,
1110            format!("could not create the coverage dir: {e}"),
1111        );
1112    }
1113    cmd.env("NODE_V8_COVERAGE", &cov_dir);
1114    if let Some(hex) = seed_hex {
1115        cmd.env("BYNK_TEST_SEED", hex);
1116    }
1117    filter.apply(program, &mut cmd);
1118
1119    let collect = || {
1120        crate::coverage::collect_coverage(&cov_dir, out_js_root, out_root, source_root)
1121            .unwrap_or_default()
1122    };
1123
1124    let code = if json {
1125        cmd.env("BYNK_TEST_FORMAT", "ndjson");
1126        let out = match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output() {
1127            Ok(o) => o,
1128            Err(e) => {
1129                let _ = std::fs::remove_dir_all(&cov_dir);
1130                return coverage_unsupported(program, true, format!("could not run node: {e}"));
1131            }
1132        };
1133        let stdout = String::from_utf8_lossy(&out.stdout);
1134        let stderr = String::from_utf8_lossy(&out.stderr);
1135        let report = collect();
1136        let parsed = crate::test_json::parse_ndjson(&stdout);
1137        let unmatched = parsed.unmatched.clone();
1138        let doc = parsed
1139            .into_document(&stderr)
1140            .with_tiers(discovered)
1141            .with_coverage(&report);
1142        print!("{}", doc.render());
1143        unmatched.report(program);
1144        exit_from(out.status.success())
1145    } else {
1146        let status = cmd
1147            .stdout(Stdio::inherit())
1148            .stderr(Stdio::inherit())
1149            .status();
1150        let status = match status {
1151            Ok(s) => s,
1152            Err(e) => {
1153                let _ = std::fs::remove_dir_all(&cov_dir);
1154                eprintln!("{program} test --coverage: could not run node: {e}");
1155                return ExitCode::FAILURE;
1156            }
1157        };
1158        let report = collect();
1159        print!("{}", crate::coverage::render_rich(&report));
1160        exit_from(status.success())
1161    };
1162    let _ = std::fs::remove_dir_all(&cov_dir);
1163    code
1164}
1165
1166/// #2146: mark the tsc output tree as ES modules, so Node runs the ESM that tsc
1167/// emits from the `"type": "module"` output tree (#2148).
1168fn write_esm_marker(out_js_root: &Path) -> std::io::Result<()> {
1169    std::fs::create_dir_all(out_js_root)?;
1170    std::fs::write(
1171        out_js_root.join("package.json"),
1172        "{\n  \"type\": \"module\"\n}\n",
1173    )
1174}
1175
1176/// Node's default inspector port.
1177const NODE_INSPECT_PORT: u16 = 9229;
1178
1179/// The `--inspect-brk` flag for `run_inspect` (#2115). Node's default port,
1180/// 9229, when it is free, so a debugger that looks there by default (Chrome's
1181/// `chrome://inspect`) still finds the runner. When something else holds it
1182/// (another inspected process, a stray `workerd`), plain `--inspect-brk` would
1183/// print `Starting inspector on 127.0.0.1:9229 failed: address already in use`
1184/// and run the tests *without* an inspector, so no debugger could attach. Then
1185/// ask for port 0: Node picks a free one and prints it in the `ws://` URL, which
1186/// is what attaching clients (the VS Code extension included) read.
1187fn inspect_brk_arg(default_port_free: bool) -> &'static str {
1188    if default_port_free {
1189        "--inspect-brk"
1190    } else {
1191        "--inspect-brk=127.0.0.1:0"
1192    }
1193}
1194
1195/// Slice 2 (ADR 0104): launch the emitted `.ts` test entry under Node's inspector
1196/// and hand off. Node prints its inspector `ws://` URL to stderr and pauses at the
1197/// first line (`--inspect-brk`) until a JavaScript debugger attaches; breakpoints
1198/// set in `.bynk` resolve through the emitted source maps. `--experimental-strip-types`
1199/// runs the `.ts` directly under line-preserving type-stripping (Node ≥ 22.6;
1200/// unflagged ≥ 23.6) — no `tsc`, so slice 1's `.ts.map` applies to the running file.
1201fn run_inspect(
1202    program: &str,
1203    entry: &Path,
1204    seed_hex: Option<&str>,
1205    filter: &crate::test_filter::TestFilter,
1206) -> ExitCode {
1207    let Some(node) = resolve_tool("node") else {
1208        eprintln!("{program} test --inspect: `node` was not found on PATH");
1209        return ExitCode::FAILURE;
1210    };
1211    eprintln!("{program} test --inspect: launching the test runner under Node's inspector.");
1212    eprintln!("  Attach a JavaScript debugger to the inspector URL below; breakpoints set");
1213    eprintln!("  in `.bynk` sources resolve through the emitted source maps.");
1214    eprintln!("  (Requires Node \u{2265} 22.6 for TypeScript type-stripping.)");
1215    let mut cmd = ProcCommand::new(node);
1216    if let Some(hex) = seed_hex {
1217        cmd.env("BYNK_TEST_SEED", hex);
1218    }
1219    filter.apply(program, &mut cmd);
1220    let default_free = std::net::TcpListener::bind(("127.0.0.1", NODE_INSPECT_PORT)).is_ok();
1221    if !default_free {
1222        eprintln!(
1223            "  Port {NODE_INSPECT_PORT} is in use, so the inspector takes a free port; use the URL below."
1224        );
1225    }
1226    cmd.arg("--experimental-strip-types")
1227        .arg(inspect_brk_arg(default_free))
1228        .arg(entry);
1229    match cmd
1230        .stdout(Stdio::inherit())
1231        .stderr(Stdio::inherit())
1232        .status()
1233    {
1234        Ok(s) => exit_from(s.success()),
1235        Err(e) => {
1236            eprintln!("{program} test --inspect: could not run node: {e}");
1237            ExitCode::FAILURE
1238        }
1239    }
1240}
1241
1242#[cfg(test)]
1243mod inspect_port_tests {
1244    use super::inspect_brk_arg;
1245
1246    #[test]
1247    fn uses_the_default_port_when_free_and_a_free_port_otherwise() {
1248        assert_eq!(inspect_brk_arg(true), "--inspect-brk");
1249        assert_eq!(inspect_brk_arg(false), "--inspect-brk=127.0.0.1:0");
1250    }
1251}