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 check = bynk_emit::project::check_project(
402 &options.clone().sources(docs.check_overlay(sources)),
403 );
404 let errors = docs.remap(check.errors);
405 if !errors.is_empty() {
406 let failure = bynk_emit::project::ProjectFailure {
407 errors,
408 snapshots: check.snapshots,
409 display_root: check.display_root,
410 };
411 if json {
412 crate::test_stream::print_run(
413 stream,
414 &TestRun::compile_error(crate::project_failure_short_lines(&failure)),
415 );
416 } else {
417 crate::print_project_failure(&failure);
418 }
419 return ExitCode::FAILURE;
420 }
421 if docs.has_runnable() {
422 out = match bynk_emit::project::compile_project(
423 &options.clone().sources(docs.run_overlay(sources)),
424 ) {
425 Ok(out) => out,
426 Err(failure) => {
427 if json {
428 crate::test_stream::print_run(
429 stream,
430 &TestRun::compile_error(crate::project_failure_short_lines(
431 &failure,
432 )),
433 );
434 } else {
435 crate::print_project_failure(&failure);
436 }
437 return ExitCode::FAILURE;
438 }
439 };
440 doc_aliases = docs.run_aliases();
441 // A doc test's location is the real file's, line and column.
442 for case in out.discovered.iter_mut().flat_map(|s| s.cases.iter_mut()) {
443 if let Some(loc) = case.location.as_mut() {
444 // The synthetic file is where a doc example's case lives.
445 case.doc = crate::doc_examples::is_synthetic_path(&loc.path, &doc_aliases);
446 loc.path = crate::doc_examples::apply_aliases(&loc.path, &doc_aliases)
447 .into_owned();
448 }
449 }
450 }
451 }
452 }
453 // v0.67: `--no-run --format json` is pure discovery — render the suite/case
454 // manifest the compile retained and stop. No TS is written, no `tsc`/`node`
455 // runs, and the integration workers re-compile below is skipped (the manifest
456 // already carries integration suites from the compile above). A compile
457 // failure took the `compile`-error path above, exactly as a run would.
458 if no_run && json {
459 crate::test_stream::print_run(stream, &TestRun::discovered(discovery_suites(&out)));
460 return ExitCode::SUCCESS;
461 }
462 // #2138: the cases the remote build skipped, and the target, named before
463 // the first request.
464 if let Some(run) = remote.as_mut() {
465 run.skipped = out
466 .discovered
467 .iter()
468 .flat_map(|s| s.skipped.iter().map(|k| (s.name.clone(), k.clone())))
469 .collect();
470 eprint!("{program} test: {}", run.banner());
471 if !json {
472 for (suite, skip) in run
473 .skipped
474 .iter()
475 .filter(|(suite, skip)| filter.selects(suite, &skip.case))
476 {
477 eprintln!(
478 "{program} test: skipped `{suite}` case \"{}\" [{}]: {}",
479 skip.case, skip.code, skip.reason
480 );
481 }
482 }
483 }
484
485 // Write every artefact to disk under the output root.
486 let mut wrote_any_test = false;
487 let mut has_integration = false;
488 for (path, doc) in &out.artefacts.docs {
489 // Map-aware write (slice 2): carries the `.ts.map` siblings + trailers so
490 // a debug run (`--inspect`) can resolve `.bynk` breakpoints. Harmless for a
491 // normal run, which transpiles via `tsc` and ignores the trailer.
492 if let Err(e) = crate::write_document(path, doc, &out.artefacts.docs, &output_root) {
493 eprintln!(
494 "{program} test: could not write `{}`: {e}",
495 output_root.join(path).display()
496 );
497 return ExitCode::FAILURE;
498 }
499 let rel = path.to_string_lossy();
500 if rel.starts_with("tests/") {
501 wrote_any_test = true;
502 }
503 if rel.starts_with("tests/integration_") {
504 has_integration = true;
505 }
506 }
507
508 // v0.16: integration tests stand their participants up as real Workers, so
509 // they import the workers-mode output (`workers/**`) and the serialise/
510 // deserialise helpers the workers commons emit. The bundle compile above
511 // does not produce those, so run a second compile in workers mode and
512 // overlay everything except the `tests/` tree (whose unit modules import
513 // the bundle output). The workers commons are a strict superset of the
514 // bundle ones, so overwriting them is safe for the bundle code too.
515 let mut workers_modules = None;
516 if has_integration {
517 // v0.115/slice 2: reuse `options` (not a fresh `project_options(&input)`)
518 // so this second compile keeps `contracts(true)` and, under `--inspect`,
519 // `import_ext(Ts)` — a from-scratch rebuild silently dropped both.
520 let workers_out =
521 bynk_emit::project::compile_project(&options.clone().target(BuildTarget::Workers));
522 let workers_out = match workers_out {
523 Ok(o) => o,
524 Err(failure) => {
525 if json {
526 crate::test_stream::print_run(
527 stream,
528 &TestRun::compile_error(crate::project_failure_short_lines(&failure)),
529 );
530 } else {
531 crate::print_project_failure(&failure);
532 }
533 return ExitCode::FAILURE;
534 }
535 };
536 for (path, doc) in &workers_out.artefacts.docs {
537 if path.to_string_lossy().starts_with("tests/") {
538 continue;
539 }
540 if let Err(e) =
541 crate::write_document(path, doc, &workers_out.artefacts.docs, &output_root)
542 {
543 eprintln!(
544 "{program} test: could not write `{}`: {e}",
545 output_root.join(path).display()
546 );
547 return ExitCode::FAILURE;
548 }
549 }
550 workers_modules = Some(workers_out.artefacts.modules.clone());
551 }
552
553 // #2321: the WebAssembly modules adapter bindings include, with the Node form
554 // of each loader (after every document write, which restores the Workers
555 // form).
556 for modules in [Some(&out.artefacts.modules), workers_modules.as_ref()]
557 .into_iter()
558 .flatten()
559 {
560 if let Err(e) = crate::write_modules_for_node(modules, &output_root) {
561 eprintln!("{program} test: could not write a WebAssembly module: {e}");
562 return ExitCode::FAILURE;
563 }
564 }
565
566 if !wrote_any_test {
567 if json {
568 crate::test_stream::print_run(stream, &empty_run());
569 } else {
570 eprintln!(
571 "{program} test: no test declarations found in `{}`",
572 input.display()
573 );
574 }
575 return ExitCode::SUCCESS;
576 }
577
578 let main_ts = output_root.join("tests").join("main.ts");
579 if no_run {
580 // Rich `--no-run` is the CI emit helper: write the runner modules and
581 // report where they landed. (JSON `--no-run` already returned above with
582 // the discovery document — it never reaches here.)
583 eprintln!("{program} test: tests emitted to {}", main_ts.display());
584 return ExitCode::SUCCESS;
585 }
586
587 // Slice 2 (ADR 0104): launch the emitted `.ts` test entry directly under
588 // Node's inspector. No `tsc` — the `.ts` runs under line-preserving
589 // type-stripping, so the source maps written above resolve `.bynk`
590 // breakpoints. Node prints its inspector URL; a debugger attaches there.
591 if inspect {
592 return run_inspect(program, &main_ts, seed_hex.as_deref(), &filter);
593 }
594
595 let tsconfig = output_root.join("tsconfig.json");
596 // #854: coverage needs tsc's `.js.map`s (remap hop 1). Overwrite the default
597 // tsconfig the compile wrote with the `sourceMap: true` variant, kept
598 // coverage-only so a normal test run / deployment build ships no maps.
599 if coverage
600 && let Err(e) = std::fs::write(
601 &tsconfig,
602 bynk_emit::emitter::emit_tsconfig_with_source_maps(),
603 )
604 {
605 return coverage_unsupported(
606 program,
607 json,
608 format!("could not enable source maps for coverage: {e}"),
609 );
610 }
611 // Preferred: `tsc -p out/tsconfig.json` → `node out-js/tests/main.js`.
612 // tsc gives us full type-checking before execution and matches what a
613 // production deployment build would do. If tsc is missing, fall back to
614 // tsx, which compiles-and-runs in one step. We also try npx-mediated
615 // variants so a developer with `npm` available doesn't need a global
616 // install. If nothing works, emit an actionable error message.
617 let out_js_root = output_root
618 .parent()
619 .map(|p| p.join("out-js"))
620 .unwrap_or_else(|| PathBuf::from("out-js"));
621 let main_js = out_js_root.join("tests").join("main.js");
622
623 // #2146: tsc emits ES modules (the compile writes `out/package.json`
624 // declaring `"type": "module"`, which the emitted tsconfig's `module:
625 // NodeNext` follows, #2148), but `out-js` sits in no such `package.json`
626 // scope, so mark it ESM for Node to run the result as such. The `out/`
627 // tree is left as the compiler wrote it.
628 if let Err(e) = write_esm_marker(&out_js_root) {
629 let msg = format!(
630 "could not write {}: {e}",
631 out_js_root.join("package.json").display()
632 );
633 if json {
634 crate::test_stream::print_run(stream, &TestRun::runtime_error(msg, None));
635 } else {
636 eprintln!("{program} test: {msg}");
637 }
638 return ExitCode::FAILURE;
639 }
640
641 // Try a sequence of (program, prefix args) tsc invocations. In JSON mode the
642 // tsc step is captured so its output never reaches stdout (the document is
643 // the only thing on stdout); a tsc failure on the emitted TS is a
644 // toolchain/internal problem, surfaced as a `runtime` error.
645 // #1672: the `npx` fallback provisions the TypeScript major the output is
646 // verified against and users get, not an older pin.
647 let typescript_pkg = format!("typescript@{}", bynk_emit::TYPESCRIPT_MAJOR_TESTED);
648 let mut start_failures: Vec<String> = Vec::new();
649 let tsc_runners: Vec<(&str, Vec<&str>)> = vec![
650 ("tsc", vec![]),
651 ("npx", vec!["--yes", "-p", typescript_pkg.as_str(), "tsc"]),
652 ];
653 for (prog, prefix) in &tsc_runners {
654 let Some(path) = resolve_tool(prog) else {
655 continue;
656 };
657 let mut cmd = ProcCommand::new(&path);
658 for p in prefix {
659 cmd.arg(p);
660 }
661 cmd.arg("-p").arg(&tsconfig);
662 let tsc_ok = if json {
663 match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output() {
664 Ok(out) if out.status.success() => true,
665 Ok(out) => {
666 // tsc writes its diagnostics to *stdout*; capturing only
667 // stderr left the document's most useful field empty.
668 let mut detail = String::from_utf8_lossy(&out.stdout).into_owned();
669 let err_text = String::from_utf8_lossy(&out.stderr);
670 if !err_text.trim().is_empty() {
671 if !detail.is_empty() {
672 detail.push('\n');
673 }
674 detail.push_str(&err_text);
675 }
676 crate::test_stream::print_run(
677 stream,
678 &TestRun::runtime_error(
679 "tsc rejected the generated TypeScript",
680 Some(detail),
681 ),
682 );
683 return ExitCode::FAILURE;
684 }
685 Err(e) => {
686 start_failures.push(start_failure(&path, &e));
687 continue;
688 }
689 }
690 } else {
691 match cmd
692 .stdout(Stdio::inherit())
693 .stderr(Stdio::inherit())
694 .status()
695 {
696 Ok(s) if s.success() => true,
697 Ok(_) => {
698 eprintln!(
699 "{program} test: tsc reported errors against {}",
700 tsconfig.display()
701 );
702 return ExitCode::FAILURE;
703 }
704 Err(e) => {
705 start_failures.push(start_failure(&path, &e));
706 continue;
707 }
708 }
709 };
710 if tsc_ok {
711 let mut node_cmd = ProcCommand::new(crate::probe::program_path("node"));
712 node_cmd.arg(&main_js);
713 // #854: the coverage path owns the node launch (it sets
714 // `NODE_V8_COVERAGE` and reads the result back), so it does not go
715 // through `finish_runner`.
716 if coverage {
717 return run_with_coverage(
718 program,
719 node_cmd,
720 json,
721 seed_hex.as_deref(),
722 &filter,
723 &out_js_root,
724 &output_root,
725 &input,
726 &out.discovered,
727 );
728 }
729 return match finish_runner(
730 program,
731 node_cmd,
732 format,
733 seed_hex.as_deref(),
734 &filter,
735 remote.as_ref(),
736 &out.discovered,
737 &doc_aliases,
738 ) {
739 Ok(code) => code,
740 Err(e) => {
741 if json {
742 crate::test_stream::print_run(
743 stream,
744 &TestRun::runtime_error(format!("could not run node: {e}"), None),
745 );
746 } else {
747 eprintln!(
748 "{program} test: tsc succeeded but `node {}` failed: {e}",
749 main_js.display()
750 );
751 }
752 ExitCode::FAILURE
753 }
754 };
755 }
756 }
757
758 // #854 DECISION C: with `--coverage`, the `tsc → node` path is required — do
759 // not silently fall through to `tsx`, whose on-the-fly transform muddies
760 // which map applies. Fail clearly instead.
761 if coverage {
762 // #1761 review: a `tsc` that is installed but won't start is reported as
763 // such here too, not as missing.
764 if !start_failures.is_empty() {
765 return report_start_failures(program, json, stream, &start_failures, true);
766 }
767 return coverage_unsupported(
768 program,
769 json,
770 "`--coverage` requires `tsc` and `node` on PATH (the CI-shaped path with `.js.map`s); the `tsx` fallback is not supported for coverage.",
771 );
772 }
773
774 // tsx fallback chain.
775 let tsx_runners: Vec<(&str, Vec<&str>)> = vec![("tsx", vec![]), ("npx", vec!["--yes", "tsx"])];
776 for (prog, prefix) in &tsx_runners {
777 let Some(path) = resolve_tool(prog) else {
778 continue;
779 };
780 let mut cmd = ProcCommand::new(&path);
781 for p in prefix {
782 cmd.arg(p);
783 }
784 cmd.arg(&main_ts);
785 match finish_runner(
786 program,
787 cmd,
788 format,
789 seed_hex.as_deref(),
790 &filter,
791 remote.as_ref(),
792 &out.discovered,
793 &doc_aliases,
794 ) {
795 Ok(code) => return code,
796 Err(e) => start_failures.push(start_failure(&path, &e)),
797 }
798 }
799
800 if !start_failures.is_empty() {
801 return report_start_failures(program, json, stream, &start_failures, false);
802 }
803
804 if json {
805 crate::test_stream::print_run(
806 stream,
807 &TestRun::runtime_error(
808 "no test runner found: requires `tsc` (with Node.js) or `tsx` on PATH",
809 None,
810 ),
811 );
812 } else {
813 eprintln!(
814 "{program} test: requires either `tsc` (with Node.js) or `tsx` on PATH. \
815 Install one of:\n{}",
816 install_advice(false)
817 );
818 }
819 ExitCode::FAILURE
820}
821
822/// #1758: the run found runners but none would start. Each failure names the
823/// resolved path and the error. In rich mode the install advice follows, since
824/// installing another runner is often the fix for a broken one. `coverage`
825/// limits that advice to `tsc`, the only runner `--coverage` accepts.
826fn report_start_failures(
827 program: &str,
828 json: bool,
829 stream: bool,
830 failures: &[String],
831 coverage: bool,
832) -> ExitCode {
833 if json {
834 crate::test_stream::print_run(
835 stream,
836 &TestRun::runtime_error("no test runner could be started", Some(failures.join("\n"))),
837 );
838 } else {
839 eprintln!("{program} test: no test runner could be started:");
840 for failure in failures {
841 eprintln!(" - {failure}");
842 }
843 eprintln!("Or install another:\n{}", install_advice(coverage));
844 }
845 ExitCode::FAILURE
846}
847
848/// The install advice for a missing or broken runner, one indented line each.
849/// With `coverage`, only `tsc`, since `--coverage` doesn't accept `tsx`.
850fn install_advice(coverage: bool) -> String {
851 let tsc = format!(
852 " - `npm install -g typescript@{}` (provides tsc, which type-checks; requires Node.js to run output)",
853 bynk_emit::TYPESCRIPT_MAJOR_TESTED
854 );
855 if coverage {
856 return tsc;
857 }
858 format!(
859 "{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."
860 )
861}
862
863/// A normal run with no suites — the JSON-mode document for a project with no
864/// tests, or `--no-run`.
865fn empty_run() -> TestRun {
866 TestRun::empty()
867}
868
869/// v0.67: map the compile's retained test manifest into discovery [`Suite`]s for
870/// the `--no-run --format json` document. Each case is `outcome: "discovered"`,
871/// carrying its declaration `location` (when known) for editor click-through.
872fn discovery_suites(out: &ProjectOutput) -> Vec<Suite> {
873 out.discovered
874 .iter()
875 .map(|s| Suite {
876 name: s.name.clone(),
877 kind: s.kind.to_string(),
878 cases: s
879 .cases
880 .iter()
881 .map(|c| Case {
882 name: c.name.clone(),
883 outcome: "discovered".to_string(),
884 message: None,
885 location: c.location.as_ref().map(|l| Location {
886 path: l.path.clone(),
887 line: l.line,
888 col: l.col,
889 }),
890 retries: None,
891 tier: c.tier.map(str::to_string),
892 kind: c.generative.map(str::to_string),
893 seed: None,
894 counterexample: None,
895 violation: None,
896 refusals: None,
897 origin: c.doc.then(|| "doc".to_string()),
898 duration_ms: None,
899 })
900 .collect(),
901 })
902 .collect()
903}
904
905/// Execute the built runner command and produce its exit code. In JSON mode the
906/// runner's stdout (NDJSON) and stderr are captured, folded into the pinned
907/// document, and printed; in NDJSON mode each event is forwarded as the runner
908/// writes it ([`crate::test_stream`]); otherwise stdio is inherited so the human
909/// ✓ / ✗ output flows straight through. Either way the **exit code follows the runner's own
910/// process status**, so a mid-run crash (a complete NDJSON prefix but no
911/// `run-end`) is never reported as success.
912#[allow(clippy::too_many_arguments)]
913fn finish_runner(
914 program: &str,
915 mut cmd: ProcCommand,
916 format: TestFormat,
917 seed_hex: Option<&str>,
918 filter: &crate::test_filter::TestFilter,
919 remote: Option<&crate::remote::RemoteRun>,
920 discovered: &[bynk_emit::project::DiscoveredSuite],
921 aliases: &[(String, String)],
922) -> std::io::Result<ExitCode> {
923 if let Some(hex) = seed_hex {
924 cmd.env("BYNK_TEST_SEED", hex);
925 }
926 // #2138: the remote harness reads each Worker's URL from here; the
927 // target's secrets are already in the inherited environment.
928 if let Some(run) = remote {
929 cmd.env("BYNK_TEST_TARGETS", run.targets_json());
930 cmd.env(crate::remote::SECRETS_ENV, run.secrets_json());
931 cmd.env("BYNK_TEST_SETTLE_MS", run.settle_ms.to_string());
932 // #2138: the run's own `run.id`, always set, so a value left in the
933 // environment never makes two runs share one.
934 cmd.env(crate::remote::RUN_ID_ENV, &run.run_id);
935 }
936 // The skipped cases the filter selects are not in the build, so the runner
937 // is told they exist, and the document lists only those.
938 let selected_skips: Vec<&(String, bynk_emit::project::RemoteSkip)> = remote
939 .map(|run| {
940 run.skipped
941 .iter()
942 .filter(|(suite, skip)| filter.selects(suite, &skip.case))
943 .collect()
944 })
945 .unwrap_or_default();
946 filter
947 .clone()
948 .with_skipped(
949 selected_skips
950 .iter()
951 .map(|(suite, skip)| (suite.as_str(), skip.case.as_str())),
952 )
953 .apply(program, &mut cmd);
954 if format == TestFormat::Ndjson {
955 // #2267: forward each event as the runner writes it.
956 cmd.env("BYNK_TEST_FORMAT", "ndjson");
957 let mut events = crate::test_stream::Stream::new(crate::test_stream::StreamContext {
958 discovered,
959 skipped: selected_skips
960 .iter()
961 .map(|(suite, k)| (suite.clone(), k.case.clone(), k.reason.clone()))
962 .collect(),
963 transport: remote.map(crate::remote::RemoteRun::transport),
964 });
965 let status =
966 crate::test_stream::forward(cmd, &mut events, &mut std::io::stdout().lock(), aliases)?;
967 events.unmatched().report(program);
968 return Ok(exit_from(status.success()));
969 }
970 if format == TestFormat::Json {
971 cmd.env("BYNK_TEST_FORMAT", "ndjson");
972 let out = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output()?;
973 let stdout = String::from_utf8_lossy(&out.stdout);
974 let stderr = String::from_utf8_lossy(&out.stderr);
975 let stdout = crate::doc_examples::apply_aliases(&stdout, aliases);
976 let stderr = crate::doc_examples::apply_aliases(&stderr, aliases);
977 let parsed = crate::test_json::parse_ndjson(&stdout);
978 let unmatched = parsed.unmatched.clone();
979 let mut doc = parsed.into_document(&stderr).with_tiers(discovered);
980 if let Some(run) = remote {
981 let skipped: Vec<(String, String, String)> = selected_skips
982 .iter()
983 .map(|(suite, k)| (suite.clone(), k.case.clone(), k.reason.clone()))
984 .collect();
985 doc = doc.with_remote(run.transport(), &skipped);
986 }
987 print!("{}", doc.render());
988 unmatched.report(program);
989 Ok(exit_from(out.status.success()))
990 } else if aliases.is_empty() {
991 let status = cmd
992 .stdout(Stdio::inherit())
993 .stderr(Stdio::inherit())
994 .status()?;
995 Ok(exit_from(status.success()))
996 } else {
997 // #2461: a doc test's output names the real file, not the synthetic
998 // one it was compiled from, so the lines are rewritten on the way.
999 Ok(exit_from(run_aliased(cmd, aliases)?.success()))
1000 }
1001}
1002
1003/// Run `cmd`, forwarding its output line by line with the synthetic file names
1004/// in `aliases` replaced by the real ones.
1005fn run_aliased(
1006 mut cmd: ProcCommand,
1007 aliases: &[(String, String)],
1008) -> std::io::Result<std::process::ExitStatus> {
1009 use std::io::{BufRead, BufReader, Write};
1010 // Reads raw bytes a line at a time (as `test_stream::forward` does), so a
1011 // line that is not UTF-8 is passed on lossily and never ends the reading: a
1012 // pipe nobody reads fills and stalls the child. A final line with no newline
1013 // keeps having none.
1014 fn forward(pipe: impl std::io::Read, aliases: &[(String, String)], mut out: impl Write) {
1015 let mut reader = BufReader::new(pipe);
1016 let mut buffer = Vec::new();
1017 loop {
1018 buffer.clear();
1019 match reader.read_until(b'\n', &mut buffer) {
1020 Ok(0) | Err(_) => break,
1021 Ok(_) => {}
1022 }
1023 let line = String::from_utf8_lossy(&buffer);
1024 let line = crate::doc_examples::apply_aliases(&line, aliases);
1025 let _ = out.write_all(line.as_bytes());
1026 let _ = out.flush();
1027 }
1028 }
1029 let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
1030 let stderr_pipe = child.stderr.take();
1031 let err_aliases = aliases.to_vec();
1032 let errors = std::thread::spawn(move || {
1033 if let Some(pipe) = stderr_pipe {
1034 forward(pipe, &err_aliases, std::io::stderr().lock());
1035 }
1036 });
1037 if let Some(pipe) = child.stdout.take() {
1038 forward(pipe, aliases, std::io::stdout().lock());
1039 }
1040 let status = child.wait()?;
1041 let _ = errors.join();
1042 Ok(status)
1043}
1044
1045fn exit_from(success: bool) -> ExitCode {
1046 if success {
1047 ExitCode::SUCCESS
1048 } else {
1049 ExitCode::FAILURE
1050 }
1051}
1052
1053/// #854: a `--coverage` request that cannot be honoured (unsupported flag combo,
1054/// no `tsc → node`, or a setup error). In JSON mode it is a `runtime` error so
1055/// the document stays the only thing on stdout; in rich mode a plain stderr line.
1056fn coverage_unsupported(program: &str, json: bool, message: impl Into<String>) -> ExitCode {
1057 let message = message.into();
1058 if json {
1059 // `--format ndjson` refuses `--coverage` before this is reachable.
1060 crate::test_stream::print_run(false, &TestRun::runtime_error(message, None));
1061 } else {
1062 eprintln!("{program} test --coverage: {message}");
1063 }
1064 ExitCode::FAILURE
1065}
1066
1067/// #854: run the emitted runner under V8 coverage and attribute the result to
1068/// `.bynk` source. Owns the `node` launch: it points `NODE_V8_COVERAGE` at a
1069/// scratch dir, runs the suite exactly as [`finish_runner`] would, then remaps
1070/// the V8 output through the emitted source maps ([`crate::coverage`]). In rich
1071/// mode the summary table is appended after the human ✓ / ✗ output; in JSON mode
1072/// the `coverage` block is folded into the pinned document. The **exit code
1073/// follows the run's own status** — coverage is a report about the run, never a
1074/// gate on it (a partial or unreadable map degrades the numbers, not the code).
1075#[allow(clippy::too_many_arguments)]
1076fn run_with_coverage(
1077 program: &str,
1078 mut cmd: ProcCommand,
1079 json: bool,
1080 seed_hex: Option<&str>,
1081 filter: &crate::test_filter::TestFilter,
1082 out_js_root: &Path,
1083 out_root: &Path,
1084 source_root: &Path,
1085 discovered: &[bynk_emit::project::DiscoveredSuite],
1086) -> ExitCode {
1087 // A scratch dir beside the build output; cleared first so a prior run's JSON
1088 // never leaks in. `NODE_V8_COVERAGE` writes one file per process on exit.
1089 let cov_dir = out_root.join(".v8-coverage");
1090 let _ = std::fs::remove_dir_all(&cov_dir);
1091 if let Err(e) = std::fs::create_dir_all(&cov_dir) {
1092 return coverage_unsupported(
1093 program,
1094 json,
1095 format!("could not create the coverage dir: {e}"),
1096 );
1097 }
1098 cmd.env("NODE_V8_COVERAGE", &cov_dir);
1099 if let Some(hex) = seed_hex {
1100 cmd.env("BYNK_TEST_SEED", hex);
1101 }
1102 filter.apply(program, &mut cmd);
1103
1104 let collect = || {
1105 crate::coverage::collect_coverage(&cov_dir, out_js_root, out_root, source_root)
1106 .unwrap_or_default()
1107 };
1108
1109 let code = if json {
1110 cmd.env("BYNK_TEST_FORMAT", "ndjson");
1111 let out = match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output() {
1112 Ok(o) => o,
1113 Err(e) => {
1114 let _ = std::fs::remove_dir_all(&cov_dir);
1115 return coverage_unsupported(program, true, format!("could not run node: {e}"));
1116 }
1117 };
1118 let stdout = String::from_utf8_lossy(&out.stdout);
1119 let stderr = String::from_utf8_lossy(&out.stderr);
1120 let report = collect();
1121 let parsed = crate::test_json::parse_ndjson(&stdout);
1122 let unmatched = parsed.unmatched.clone();
1123 let doc = parsed
1124 .into_document(&stderr)
1125 .with_tiers(discovered)
1126 .with_coverage(&report);
1127 print!("{}", doc.render());
1128 unmatched.report(program);
1129 exit_from(out.status.success())
1130 } else {
1131 let status = cmd
1132 .stdout(Stdio::inherit())
1133 .stderr(Stdio::inherit())
1134 .status();
1135 let status = match status {
1136 Ok(s) => s,
1137 Err(e) => {
1138 let _ = std::fs::remove_dir_all(&cov_dir);
1139 eprintln!("{program} test --coverage: could not run node: {e}");
1140 return ExitCode::FAILURE;
1141 }
1142 };
1143 let report = collect();
1144 print!("{}", crate::coverage::render_rich(&report));
1145 exit_from(status.success())
1146 };
1147 let _ = std::fs::remove_dir_all(&cov_dir);
1148 code
1149}
1150
1151/// #2146: mark the tsc output tree as ES modules, so Node runs the ESM that tsc
1152/// emits from the `"type": "module"` output tree (#2148).
1153fn write_esm_marker(out_js_root: &Path) -> std::io::Result<()> {
1154 std::fs::create_dir_all(out_js_root)?;
1155 std::fs::write(
1156 out_js_root.join("package.json"),
1157 "{\n \"type\": \"module\"\n}\n",
1158 )
1159}
1160
1161/// Node's default inspector port.
1162const NODE_INSPECT_PORT: u16 = 9229;
1163
1164/// The `--inspect-brk` flag for `run_inspect` (#2115). Node's default port,
1165/// 9229, when it is free, so a debugger that looks there by default (Chrome's
1166/// `chrome://inspect`) still finds the runner. When something else holds it
1167/// (another inspected process, a stray `workerd`), plain `--inspect-brk` would
1168/// print `Starting inspector on 127.0.0.1:9229 failed: address already in use`
1169/// and run the tests *without* an inspector, so no debugger could attach. Then
1170/// ask for port 0: Node picks a free one and prints it in the `ws://` URL, which
1171/// is what attaching clients (the VS Code extension included) read.
1172fn inspect_brk_arg(default_port_free: bool) -> &'static str {
1173 if default_port_free {
1174 "--inspect-brk"
1175 } else {
1176 "--inspect-brk=127.0.0.1:0"
1177 }
1178}
1179
1180/// Slice 2 (ADR 0104): launch the emitted `.ts` test entry under Node's inspector
1181/// and hand off. Node prints its inspector `ws://` URL to stderr and pauses at the
1182/// first line (`--inspect-brk`) until a JavaScript debugger attaches; breakpoints
1183/// set in `.bynk` resolve through the emitted source maps. `--experimental-strip-types`
1184/// runs the `.ts` directly under line-preserving type-stripping (Node ≥ 22.6;
1185/// unflagged ≥ 23.6) — no `tsc`, so slice 1's `.ts.map` applies to the running file.
1186fn run_inspect(
1187 program: &str,
1188 entry: &Path,
1189 seed_hex: Option<&str>,
1190 filter: &crate::test_filter::TestFilter,
1191) -> ExitCode {
1192 let Some(node) = resolve_tool("node") else {
1193 eprintln!("{program} test --inspect: `node` was not found on PATH");
1194 return ExitCode::FAILURE;
1195 };
1196 eprintln!("{program} test --inspect: launching the test runner under Node's inspector.");
1197 eprintln!(" Attach a JavaScript debugger to the inspector URL below; breakpoints set");
1198 eprintln!(" in `.bynk` sources resolve through the emitted source maps.");
1199 eprintln!(" (Requires Node \u{2265} 22.6 for TypeScript type-stripping.)");
1200 let mut cmd = ProcCommand::new(node);
1201 if let Some(hex) = seed_hex {
1202 cmd.env("BYNK_TEST_SEED", hex);
1203 }
1204 filter.apply(program, &mut cmd);
1205 let default_free = std::net::TcpListener::bind(("127.0.0.1", NODE_INSPECT_PORT)).is_ok();
1206 if !default_free {
1207 eprintln!(
1208 " Port {NODE_INSPECT_PORT} is in use, so the inspector takes a free port; use the URL below."
1209 );
1210 }
1211 cmd.arg("--experimental-strip-types")
1212 .arg(inspect_brk_arg(default_free))
1213 .arg(entry);
1214 match cmd
1215 .stdout(Stdio::inherit())
1216 .stderr(Stdio::inherit())
1217 .status()
1218 {
1219 Ok(s) => exit_from(s.success()),
1220 Err(e) => {
1221 eprintln!("{program} test --inspect: could not run node: {e}");
1222 ExitCode::FAILURE
1223 }
1224 }
1225}
1226
1227#[cfg(test)]
1228mod inspect_port_tests {
1229 use super::inspect_brk_arg;
1230
1231 #[test]
1232 fn uses_the_default_port_when_free_and_a_free_port_otherwise() {
1233 assert_eq!(inspect_brk_arg(true), "--inspect-brk");
1234 assert_eq!(inspect_brk_arg(false), "--inspect-brk=127.0.0.1:0");
1235 }
1236}