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 tool_exists(name: &str) -> bool {
crate::probe::detect(&SystemToolbox, name, DetectOpts::default()).is_present()
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, ValueEnum)]
pub enum TestFormat {
#[default]
Rich,
Json,
}
impl TestFormat {
pub fn as_bynkc_arg(self) -> &'static str {
match self {
TestFormat::Rich => "rich",
TestFormat::Json => "json",
}
}
}
#[derive(clap::Args, Debug)]
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: Option<String>,
#[arg(long)]
pub coverage: bool,
}
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,
coverage,
} = args;
let json = format == TestFormat::Json;
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 case_filter = case.filter(|c| !c.is_empty());
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.unwrap_or_else(|| input.join("out"));
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) {
Ok(o) => o.contracts(true),
Err(e) => {
if json {
print!("{}", TestRun::runtime_error(e.to_string(), None).render());
} else {
eprintln!("{program} test: {e}");
}
return ExitCode::FAILURE;
}
};
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 {
print!(
"{}",
TestRun::compile_error(crate::project_failure_short_lines(&failure)).render()
);
} else {
crate::print_project_failure(&failure);
}
return ExitCode::FAILURE;
}
};
if no_run && json {
print!("{}", TestRun::discovered(discovery_suites(&out)).render());
return ExitCode::SUCCESS;
}
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;
}
}
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 {
print!(
"{}",
TestRun::compile_error(crate::project_failure_short_lines(&failure))
.render()
);
} 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;
}
}
}
if !wrote_any_test {
if json {
print!("{}", empty_run().render());
} 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(),
case_filter.as_deref(),
);
}
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");
let typescript_pkg = format!("typescript@{}", bynk_emit::TYPESCRIPT_MAJOR_TESTED);
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 {
if !tool_exists(prog) {
continue;
}
let mut cmd = ProcCommand::new(prog);
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);
}
print!(
"{}",
TestRun::runtime_error(
"tsc rejected the generated TypeScript",
Some(detail),
)
.render()
);
return ExitCode::FAILURE;
}
Err(_) => 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(_) => continue,
}
};
if tsc_ok {
let mut node_cmd = ProcCommand::new("node");
node_cmd.arg(&main_js);
if coverage {
return run_with_coverage(
program,
node_cmd,
json,
seed_hex.as_deref(),
case_filter.as_deref(),
&out_js_root,
&output_root,
&input,
);
}
return match finish_runner(node_cmd, json, seed_hex.as_deref(), case_filter.as_deref())
{
Ok(code) => code,
Err(e) => {
if json {
print!(
"{}",
TestRun::runtime_error(format!("could not run node: {e}"), None)
.render()
);
} else {
eprintln!(
"{program} test: tsc succeeded but `node {}` failed: {e}",
main_js.display()
);
}
ExitCode::FAILURE
}
};
}
}
if coverage {
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 {
if !tool_exists(prog) {
continue;
}
let mut cmd = ProcCommand::new(prog);
for p in prefix {
cmd.arg(p);
}
cmd.arg(&main_ts);
match finish_runner(cmd, json, seed_hex.as_deref(), case_filter.as_deref()) {
Ok(code) => return code,
Err(_) => continue,
}
}
if json {
print!(
"{}",
TestRun::runtime_error(
"no test runner found: requires `tsc` (with Node.js) or `tsx` on PATH",
None
)
.render()
);
} else {
eprintln!(
"{program} test: requires either `tsc` (with Node.js) or `tsx` on PATH. \
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.",
tested = bynk_emit::TYPESCRIPT_MAJOR_TESTED,
);
}
ExitCode::FAILURE
}
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,
}),
})
.collect(),
})
.collect()
}
fn finish_runner(
mut cmd: ProcCommand,
json: bool,
seed_hex: Option<&str>,
case: Option<&str>,
) -> std::io::Result<ExitCode> {
if let Some(hex) = seed_hex {
cmd.env("BYNK_TEST_SEED", hex);
}
if let Some(name) = case {
cmd.env("BYNK_TEST_CASE", name);
}
if 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 doc = crate::test_json::parse_ndjson(&stdout).into_document(&stderr);
print!("{}", doc.render());
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 {
print!("{}", TestRun::runtime_error(message, None).render());
} 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>,
case: Option<&str>,
out_js_root: &Path,
out_root: &Path,
source_root: &Path,
) -> 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);
}
if let Some(name) = case {
cmd.env("BYNK_TEST_CASE", name);
}
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 doc = crate::test_json::parse_ndjson(&stdout)
.into_document(&stderr)
.with_coverage(&report);
print!("{}", doc.render());
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 run_inspect(
program: &str,
entry: &Path,
seed_hex: Option<&str>,
case: Option<&str>,
) -> ExitCode {
if !tool_exists("node") {
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);
}
if let Some(name) = case {
cmd.env("BYNK_TEST_CASE", name);
}
cmd.arg("--experimental-strip-types")
.arg("--inspect-brk")
.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
}
}
}