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