use std::path::{Path, PathBuf};
use std::process::{Command as ProcCommand, ExitCode, Stdio};
use bynk_emit::project::{BuildTarget, ImportExt, ProjectOutput};
use clap::ValueEnum;
use crate::probe::{DetectOpts, SystemToolbox};
use crate::test_json::{Case, Location, Suite, TestRun};
fn resolve_tool(name: &str) -> Option<PathBuf> {
crate::probe::detect(&SystemToolbox, name, DetectOpts::default())
.provenance
.path()
.map(Path::to_path_buf)
}
fn start_failure(path: &Path, e: &std::io::Error) -> String {
format!(
"`{}` was found but could not be started: {e}",
path.display()
)
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, ValueEnum)]
pub enum TestFormat {
#[default]
Rich,
Json,
Ndjson,
}
impl TestFormat {
pub fn as_bynkc_arg(self) -> &'static str {
match self {
TestFormat::Rich => "rich",
TestFormat::Json => "json",
TestFormat::Ndjson => "ndjson",
}
}
}
#[derive(clap::Args, Debug, Clone)]
pub struct TestArgs {
#[arg(default_value = ".")]
pub input: PathBuf,
#[arg(short, long)]
pub output: Option<PathBuf>,
#[arg(long)]
pub no_run: bool,
#[arg(long, value_enum, default_value_t = TestFormat::Rich)]
pub format: TestFormat,
#[arg(long)]
pub inspect: bool,
#[arg(long)]
pub seed: Option<String>,
#[arg(long, value_name = "NAME")]
pub case: Vec<String>,
#[arg(long, value_name = "NAME")]
pub suite: Vec<String>,
#[arg(long, hide = true)]
pub partial_selection: bool,
#[arg(long)]
pub coverage: bool,
#[arg(long, value_name = "TARGET")]
pub against: Option<String>,
#[arg(long, requires = "against")]
pub skip_unsupported: bool,
#[arg(
long,
value_name = "DURATION",
requires = "against",
default_value = "5s"
)]
pub settle: Option<String>,
}
fn output_root_for(input: &Path, output: Option<&Path>) -> PathBuf {
output.map_or_else(|| input.join("out"), Path::to_path_buf)
}
fn normalise_seed(raw: &str) -> Option<String> {
let hex = raw
.strip_prefix("0x")
.or_else(|| raw.strip_prefix("0X"))
.unwrap_or(raw);
if hex.is_empty() || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
Some(hex.to_string())
}
pub fn run_test(program: &str, args: TestArgs) -> ExitCode {
let TestArgs {
input,
output,
no_run,
format,
inspect,
seed,
case,
suite,
partial_selection,
coverage,
against,
skip_unsupported,
settle,
} = args;
let stream = format == TestFormat::Ndjson;
let json = format == TestFormat::Json || stream;
if stream && (coverage || no_run) {
let message = if coverage {
"`--format ndjson` cannot be combined with `--coverage`: coverage is a whole-run report, so use `--format json`."
} else {
"`--format ndjson` cannot be combined with `--no-run`: discovery is a document, so use `--format json`."
};
crate::test_stream::print_run(true, &TestRun::runtime_error(message, None));
return ExitCode::FAILURE;
}
let mut remote = match against.as_deref() {
None => None,
Some(value) => {
let refused = |message: String| {
if json {
crate::test_stream::print_run(stream, &TestRun::runtime_error(message, None));
} else {
eprintln!("{program} test: {message}");
}
ExitCode::FAILURE
};
let settle_ms = match settle.as_deref().map(crate::remote::parse_settle) {
None => crate::remote::DEFAULT_SETTLE_MS,
Some(Ok(ms)) => ms,
Some(Err(e)) => return refused(e),
};
if inspect || coverage {
return refused(
"`--against` cannot be combined with `--inspect` or `--coverage`, which observe the in-process run".to_string(),
);
}
match crate::remote::resolve(
value,
&input,
|name| std::env::var(name).ok(),
"starts a local `bynk dev`, which only the `bynk` driver can do: run `bynk test --against dev`",
) {
Ok(crate::remote::Against::Run(run)) => {
Some(crate::remote::RemoteRun { settle_ms, ..run })
}
Ok(crate::remote::Against::StartDev(bynk)) => {
return crate::dev_run::run(
program,
&crate::dev_run::DevRunArgs {
input: input.clone(),
output: output_root_for(&input, output.as_deref()),
json,
stream,
seed: seed.clone(),
filter: crate::test_filter::TestFilter::new(&suite, &case),
skip_unsupported,
settle: settle.clone(),
run_id: crate::remote::fresh_run_id(),
bynk,
},
);
}
Err(refusal) => return refused(refusal.to_string()),
}
}
};
if coverage && inspect {
return coverage_unsupported(
program,
json,
"`--coverage` cannot be combined with `--inspect` — coverage needs the `tsc → node` run, not the inspector.",
);
}
if coverage && no_run {
return coverage_unsupported(
program,
json,
"`--coverage` cannot be combined with `--no-run` — there is no run to measure.",
);
}
let filter = crate::test_filter::TestFilter::new(&suite, &case).partial(partial_selection);
let seed_hex = match seed.as_deref() {
Some(raw) => match normalise_seed(raw) {
Some(hex) => Some(hex),
None => {
if !json {
eprintln!(
"{program} test: ignoring --seed `{raw}` (not a hex value like 0x5f3a)"
);
}
None
}
},
None => None,
};
let output_root = output_root_for(&input, output.as_deref());
if !input.is_dir() {
eprintln!(
"{program} test: input `{}` must be a project directory containing `.bynk` files",
input.display()
);
return ExitCode::FAILURE;
}
let options = {
let o = match crate::try_project_options(&input)
.map_err(|e| e.to_string())
.and_then(|o| Ok(o.schema_registry(crate::schema_lock::read_only(&input)?)))
{
Ok(o) => o.contracts(true),
Err(e) => {
if json {
crate::test_stream::print_run(stream, &TestRun::runtime_error(e, None));
} else {
eprintln!("{program} test: {e}");
}
return ExitCode::FAILURE;
}
};
let o = o.remote_tests(remote.as_ref().map(|run| bynk_emit::project::RemoteTests {
skip_unsupported,
vars: run.vars.clone(),
secrets: run.secrets.clone(),
dev: run.dev_env.is_some(),
only_env: run.dev_env.clone(),
}));
if inspect {
o.import_ext(ImportExt::Ts)
} else {
o
}
};
let out = match bynk_emit::project::compile_project(&options) {
Ok(out) => out,
Err(failure) => {
if json {
crate::test_stream::print_run(
stream,
&TestRun::compile_error(crate::project_failure_short_lines(&failure)),
);
} else {
crate::print_project_failure(&failure);
}
return ExitCode::FAILURE;
}
};
if no_run && json {
crate::test_stream::print_run(stream, &TestRun::discovered(discovery_suites(&out)));
return ExitCode::SUCCESS;
}
if let Some(run) = remote.as_mut() {
run.skipped = out
.discovered
.iter()
.flat_map(|s| s.skipped.iter().map(|k| (s.name.clone(), k.clone())))
.collect();
eprint!("{program} test: {}", run.banner());
if !json {
for (suite, skip) in run
.skipped
.iter()
.filter(|(suite, skip)| filter.selects(suite, &skip.case))
{
eprintln!(
"{program} test: skipped `{suite}` case \"{}\" [{}]: {}",
skip.case, skip.code, skip.reason
);
}
}
}
let mut wrote_any_test = false;
let mut has_integration = false;
for (path, doc) in &out.artefacts.docs {
if let Err(e) = crate::write_document(path, doc, &out.artefacts.docs, &output_root) {
eprintln!(
"{program} test: could not write `{}`: {e}",
output_root.join(path).display()
);
return ExitCode::FAILURE;
}
let rel = path.to_string_lossy();
if rel.starts_with("tests/") {
wrote_any_test = true;
}
if rel.starts_with("tests/integration_") {
has_integration = true;
}
}
let mut workers_modules = None;
if has_integration {
let workers_out =
bynk_emit::project::compile_project(&options.clone().target(BuildTarget::Workers));
let workers_out = match workers_out {
Ok(o) => o,
Err(failure) => {
if json {
crate::test_stream::print_run(
stream,
&TestRun::compile_error(crate::project_failure_short_lines(&failure)),
);
} else {
crate::print_project_failure(&failure);
}
return ExitCode::FAILURE;
}
};
for (path, doc) in &workers_out.artefacts.docs {
if path.to_string_lossy().starts_with("tests/") {
continue;
}
if let Err(e) =
crate::write_document(path, doc, &workers_out.artefacts.docs, &output_root)
{
eprintln!(
"{program} test: could not write `{}`: {e}",
output_root.join(path).display()
);
return ExitCode::FAILURE;
}
}
workers_modules = Some(workers_out.artefacts.modules.clone());
}
for modules in [Some(&out.artefacts.modules), workers_modules.as_ref()]
.into_iter()
.flatten()
{
if let Err(e) = crate::write_modules_for_node(modules, &output_root) {
eprintln!("{program} test: could not write a WebAssembly module: {e}");
return ExitCode::FAILURE;
}
}
if !wrote_any_test {
if json {
crate::test_stream::print_run(stream, &empty_run());
} else {
eprintln!(
"{program} test: no test declarations found in `{}`",
input.display()
);
}
return ExitCode::SUCCESS;
}
let main_ts = output_root.join("tests").join("main.ts");
if no_run {
eprintln!("{program} test: tests emitted to {}", main_ts.display());
return ExitCode::SUCCESS;
}
if inspect {
return run_inspect(program, &main_ts, seed_hex.as_deref(), &filter);
}
let tsconfig = output_root.join("tsconfig.json");
if coverage
&& let Err(e) = std::fs::write(
&tsconfig,
bynk_emit::emitter::emit_tsconfig_with_source_maps(),
)
{
return coverage_unsupported(
program,
json,
format!("could not enable source maps for coverage: {e}"),
);
}
let out_js_root = output_root
.parent()
.map(|p| p.join("out-js"))
.unwrap_or_else(|| PathBuf::from("out-js"));
let main_js = out_js_root.join("tests").join("main.js");
if let Err(e) = write_esm_marker(&out_js_root) {
let msg = format!(
"could not write {}: {e}",
out_js_root.join("package.json").display()
);
if json {
crate::test_stream::print_run(stream, &TestRun::runtime_error(msg, None));
} else {
eprintln!("{program} test: {msg}");
}
return ExitCode::FAILURE;
}
let typescript_pkg = format!("typescript@{}", bynk_emit::TYPESCRIPT_MAJOR_TESTED);
let mut start_failures: Vec<String> = Vec::new();
let tsc_runners: Vec<(&str, Vec<&str>)> = vec![
("tsc", vec![]),
("npx", vec!["--yes", "-p", typescript_pkg.as_str(), "tsc"]),
];
for (prog, prefix) in &tsc_runners {
let Some(path) = resolve_tool(prog) else {
continue;
};
let mut cmd = ProcCommand::new(&path);
for p in prefix {
cmd.arg(p);
}
cmd.arg("-p").arg(&tsconfig);
let tsc_ok = if json {
match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output() {
Ok(out) if out.status.success() => true,
Ok(out) => {
let mut detail = String::from_utf8_lossy(&out.stdout).into_owned();
let err_text = String::from_utf8_lossy(&out.stderr);
if !err_text.trim().is_empty() {
if !detail.is_empty() {
detail.push('\n');
}
detail.push_str(&err_text);
}
crate::test_stream::print_run(
stream,
&TestRun::runtime_error(
"tsc rejected the generated TypeScript",
Some(detail),
),
);
return ExitCode::FAILURE;
}
Err(e) => {
start_failures.push(start_failure(&path, &e));
continue;
}
}
} else {
match cmd
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
{
Ok(s) if s.success() => true,
Ok(_) => {
eprintln!(
"{program} test: tsc reported errors against {}",
tsconfig.display()
);
return ExitCode::FAILURE;
}
Err(e) => {
start_failures.push(start_failure(&path, &e));
continue;
}
}
};
if tsc_ok {
let mut node_cmd = ProcCommand::new(crate::probe::program_path("node"));
node_cmd.arg(&main_js);
if coverage {
return run_with_coverage(
program,
node_cmd,
json,
seed_hex.as_deref(),
&filter,
&out_js_root,
&output_root,
&input,
&out.discovered,
);
}
return match finish_runner(
program,
node_cmd,
format,
seed_hex.as_deref(),
&filter,
remote.as_ref(),
&out.discovered,
) {
Ok(code) => code,
Err(e) => {
if json {
crate::test_stream::print_run(
stream,
&TestRun::runtime_error(format!("could not run node: {e}"), None),
);
} else {
eprintln!(
"{program} test: tsc succeeded but `node {}` failed: {e}",
main_js.display()
);
}
ExitCode::FAILURE
}
};
}
}
if coverage {
if !start_failures.is_empty() {
return report_start_failures(program, json, stream, &start_failures, true);
}
return coverage_unsupported(
program,
json,
"`--coverage` requires `tsc` and `node` on PATH (the CI-shaped path with `.js.map`s); the `tsx` fallback is not supported for coverage.",
);
}
let tsx_runners: Vec<(&str, Vec<&str>)> = vec![("tsx", vec![]), ("npx", vec!["--yes", "tsx"])];
for (prog, prefix) in &tsx_runners {
let Some(path) = resolve_tool(prog) else {
continue;
};
let mut cmd = ProcCommand::new(&path);
for p in prefix {
cmd.arg(p);
}
cmd.arg(&main_ts);
match finish_runner(
program,
cmd,
format,
seed_hex.as_deref(),
&filter,
remote.as_ref(),
&out.discovered,
) {
Ok(code) => return code,
Err(e) => start_failures.push(start_failure(&path, &e)),
}
}
if !start_failures.is_empty() {
return report_start_failures(program, json, stream, &start_failures, false);
}
if json {
crate::test_stream::print_run(
stream,
&TestRun::runtime_error(
"no test runner found: requires `tsc` (with Node.js) or `tsx` on PATH",
None,
),
);
} else {
eprintln!(
"{program} test: requires either `tsc` (with Node.js) or `tsx` on PATH. \
Install one of:\n{}",
install_advice(false)
);
}
ExitCode::FAILURE
}
fn report_start_failures(
program: &str,
json: bool,
stream: bool,
failures: &[String],
coverage: bool,
) -> ExitCode {
if json {
crate::test_stream::print_run(
stream,
&TestRun::runtime_error("no test runner could be started", Some(failures.join("\n"))),
);
} else {
eprintln!("{program} test: no test runner could be started:");
for failure in failures {
eprintln!(" - {failure}");
}
eprintln!("Or install another:\n{}", install_advice(coverage));
}
ExitCode::FAILURE
}
fn install_advice(coverage: bool) -> String {
let tsc = format!(
" - `npm install -g typescript@{}` (provides tsc, which type-checks; requires Node.js to run output)",
bynk_emit::TYPESCRIPT_MAJOR_TESTED
);
if coverage {
return tsc;
}
format!(
"{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."
)
}
fn empty_run() -> TestRun {
TestRun::empty()
}
fn discovery_suites(out: &ProjectOutput) -> Vec<Suite> {
out.discovered
.iter()
.map(|s| Suite {
name: s.name.clone(),
kind: s.kind.to_string(),
cases: s
.cases
.iter()
.map(|c| Case {
name: c.name.clone(),
outcome: "discovered".to_string(),
message: None,
location: c.location.as_ref().map(|l| Location {
path: l.path.clone(),
line: l.line,
col: l.col,
}),
retries: None,
tier: c.tier.map(str::to_string),
kind: c.generative.map(str::to_string),
seed: None,
counterexample: None,
violation: None,
refusals: None,
duration_ms: None,
})
.collect(),
})
.collect()
}
fn finish_runner(
program: &str,
mut cmd: ProcCommand,
format: TestFormat,
seed_hex: Option<&str>,
filter: &crate::test_filter::TestFilter,
remote: Option<&crate::remote::RemoteRun>,
discovered: &[bynk_emit::project::DiscoveredSuite],
) -> std::io::Result<ExitCode> {
if let Some(hex) = seed_hex {
cmd.env("BYNK_TEST_SEED", hex);
}
if let Some(run) = remote {
cmd.env("BYNK_TEST_TARGETS", run.targets_json());
cmd.env(crate::remote::SECRETS_ENV, run.secrets_json());
cmd.env("BYNK_TEST_SETTLE_MS", run.settle_ms.to_string());
cmd.env(crate::remote::RUN_ID_ENV, &run.run_id);
}
let selected_skips: Vec<&(String, bynk_emit::project::RemoteSkip)> = remote
.map(|run| {
run.skipped
.iter()
.filter(|(suite, skip)| filter.selects(suite, &skip.case))
.collect()
})
.unwrap_or_default();
filter
.clone()
.with_skipped(
selected_skips
.iter()
.map(|(suite, skip)| (suite.as_str(), skip.case.as_str())),
)
.apply(program, &mut cmd);
if format == TestFormat::Ndjson {
cmd.env("BYNK_TEST_FORMAT", "ndjson");
let mut events = crate::test_stream::Stream::new(crate::test_stream::StreamContext {
discovered,
skipped: selected_skips
.iter()
.map(|(suite, k)| (suite.clone(), k.case.clone(), k.reason.clone()))
.collect(),
transport: remote.map(crate::remote::RemoteRun::transport),
});
let status = crate::test_stream::forward(cmd, &mut events, &mut std::io::stdout().lock())?;
events.unmatched().report(program);
return Ok(exit_from(status.success()));
}
if format == TestFormat::Json {
cmd.env("BYNK_TEST_FORMAT", "ndjson");
let out = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output()?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let parsed = crate::test_json::parse_ndjson(&stdout);
let unmatched = parsed.unmatched.clone();
let mut doc = parsed.into_document(&stderr).with_tiers(discovered);
if let Some(run) = remote {
let skipped: Vec<(String, String, String)> = selected_skips
.iter()
.map(|(suite, k)| (suite.clone(), k.case.clone(), k.reason.clone()))
.collect();
doc = doc.with_remote(run.transport(), &skipped);
}
print!("{}", doc.render());
unmatched.report(program);
Ok(exit_from(out.status.success()))
} else {
let status = cmd
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()?;
Ok(exit_from(status.success()))
}
}
fn exit_from(success: bool) -> ExitCode {
if success {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn coverage_unsupported(program: &str, json: bool, message: impl Into<String>) -> ExitCode {
let message = message.into();
if json {
crate::test_stream::print_run(false, &TestRun::runtime_error(message, None));
} else {
eprintln!("{program} test --coverage: {message}");
}
ExitCode::FAILURE
}
#[allow(clippy::too_many_arguments)]
fn run_with_coverage(
program: &str,
mut cmd: ProcCommand,
json: bool,
seed_hex: Option<&str>,
filter: &crate::test_filter::TestFilter,
out_js_root: &Path,
out_root: &Path,
source_root: &Path,
discovered: &[bynk_emit::project::DiscoveredSuite],
) -> ExitCode {
let cov_dir = out_root.join(".v8-coverage");
let _ = std::fs::remove_dir_all(&cov_dir);
if let Err(e) = std::fs::create_dir_all(&cov_dir) {
return coverage_unsupported(
program,
json,
format!("could not create the coverage dir: {e}"),
);
}
cmd.env("NODE_V8_COVERAGE", &cov_dir);
if let Some(hex) = seed_hex {
cmd.env("BYNK_TEST_SEED", hex);
}
filter.apply(program, &mut cmd);
let collect = || {
crate::coverage::collect_coverage(&cov_dir, out_js_root, out_root, source_root)
.unwrap_or_default()
};
let code = if json {
cmd.env("BYNK_TEST_FORMAT", "ndjson");
let out = match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output() {
Ok(o) => o,
Err(e) => {
let _ = std::fs::remove_dir_all(&cov_dir);
return coverage_unsupported(program, true, format!("could not run node: {e}"));
}
};
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let report = collect();
let parsed = crate::test_json::parse_ndjson(&stdout);
let unmatched = parsed.unmatched.clone();
let doc = parsed
.into_document(&stderr)
.with_tiers(discovered)
.with_coverage(&report);
print!("{}", doc.render());
unmatched.report(program);
exit_from(out.status.success())
} else {
let status = cmd
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
let status = match status {
Ok(s) => s,
Err(e) => {
let _ = std::fs::remove_dir_all(&cov_dir);
eprintln!("{program} test --coverage: could not run node: {e}");
return ExitCode::FAILURE;
}
};
let report = collect();
print!("{}", crate::coverage::render_rich(&report));
exit_from(status.success())
};
let _ = std::fs::remove_dir_all(&cov_dir);
code
}
fn write_esm_marker(out_js_root: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(out_js_root)?;
std::fs::write(
out_js_root.join("package.json"),
"{\n \"type\": \"module\"\n}\n",
)
}
const NODE_INSPECT_PORT: u16 = 9229;
fn inspect_brk_arg(default_port_free: bool) -> &'static str {
if default_port_free {
"--inspect-brk"
} else {
"--inspect-brk=127.0.0.1:0"
}
}
fn run_inspect(
program: &str,
entry: &Path,
seed_hex: Option<&str>,
filter: &crate::test_filter::TestFilter,
) -> ExitCode {
let Some(node) = resolve_tool("node") else {
eprintln!("{program} test --inspect: `node` was not found on PATH");
return ExitCode::FAILURE;
};
eprintln!("{program} test --inspect: launching the test runner under Node's inspector.");
eprintln!(" Attach a JavaScript debugger to the inspector URL below; breakpoints set");
eprintln!(" in `.bynk` sources resolve through the emitted source maps.");
eprintln!(" (Requires Node \u{2265} 22.6 for TypeScript type-stripping.)");
let mut cmd = ProcCommand::new(node);
if let Some(hex) = seed_hex {
cmd.env("BYNK_TEST_SEED", hex);
}
filter.apply(program, &mut cmd);
let default_free = std::net::TcpListener::bind(("127.0.0.1", NODE_INSPECT_PORT)).is_ok();
if !default_free {
eprintln!(
" Port {NODE_INSPECT_PORT} is in use, so the inspector takes a free port; use the URL below."
);
}
cmd.arg("--experimental-strip-types")
.arg(inspect_brk_arg(default_free))
.arg(entry);
match cmd
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
{
Ok(s) => exit_from(s.success()),
Err(e) => {
eprintln!("{program} test --inspect: could not run node: {e}");
ExitCode::FAILURE
}
}
}
#[cfg(test)]
mod inspect_port_tests {
use super::inspect_brk_arg;
#[test]
fn uses_the_default_port_when_free_and_a_free_port_otherwise() {
assert_eq!(inspect_brk_arg(true), "--inspect-brk");
assert_eq!(inspect_brk_arg(false), "--inspect-brk=127.0.0.1:0");
}
}