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