use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::commands::build::burger_build_cli;
use crate::commands::burger_bin::BurgerBinSearch;
pub const BURGER_TEST_OUT: &str = "target/burger-test";
#[derive(Debug)]
pub struct TestArgs {
pub path: PathBuf,
pub filters: Vec<String>,
pub junit: Option<PathBuf>,
}
#[derive(Debug, Deserialize, PartialEq, Eq)]
pub struct TestPlanEntry {
pub source: PathBuf,
pub output: PathBuf,
}
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TestPlan {
pub import_map: PathBuf,
pub tests: Vec<TestPlanEntry>,
}
pub fn run(args: TestArgs) -> Result<()> {
let project = args
.path
.canonicalize()
.with_context(|| format!("canonicalize project path '{}'", args.path.display()))?;
let manifest_path = project.join("manifest.json");
let raw = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("read {}", manifest_path.display()))?;
let manifest: serde_json::Value =
serde_json::from_str(&raw).with_context(|| format!("parse {}", manifest_path.display()))?;
let cwd = std::env::current_dir().context("read the current directory")?;
let junit = args.junit.as_deref().map(|junit| resolve_from(&cwd, junit));
if let Some(dir) = junit.as_deref().and_then(Path::parent) {
std::fs::create_dir_all(dir)
.with_context(|| format!("create the --junit directory {}", dir.display()))?;
}
match manifest.get("app_type").and_then(|v| v.as_str()) {
Some("bun") => run_checked(
Command::new("bun").args(bun_test_args(&args.filters, junit.as_deref())),
&project,
"bun test",
),
Some("burger") => run_burger(&project, &args.filters, junit.as_deref()),
other => {
bail!("node-app test supports app_type \"bun\" and \"burger\" (manifest has {other:?})")
}
}
}
pub fn resolve_from(cwd: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
}
pub fn no_tests_message(has_tests_dir: bool, filters: &[String]) -> String {
if !filters.is_empty() {
return format!("no test files match {filters:?}");
}
let dirs = if has_tests_dir {
"src/ or tests/"
} else {
"src/"
};
format!("no test files (*.test.ts) under {dirs}")
}
pub fn bun_test_args(filters: &[String], junit: Option<&Path>) -> Vec<OsString> {
let mut args: Vec<OsString> = vec!["test".into()];
if let Some(junit) = junit {
args.push("--reporter=junit".into());
let mut outfile = OsString::from("--reporter-outfile=");
outfile.push(junit);
args.push(outfile);
}
args.extend(filters.iter().map(OsString::from));
args
}
pub fn prepare_args(cli: &Path, has_tests_dir: bool) -> Vec<OsString> {
let mut args: Vec<OsString> = vec![
cli.into(),
"test-prepare".into(),
"--src".into(),
"src".into(),
];
if has_tests_dir {
args.push("--tests".into());
args.push("tests".into());
}
args.push("--out".into());
args.push(BURGER_TEST_OUT.into());
args
}
pub fn parse_test_plan(json: &str) -> Result<TestPlan> {
serde_json::from_str(json).context("parse burger-build test-plan.json")
}
pub fn select_tests(plan: &TestPlan, project: &Path, filters: &[String]) -> Vec<PathBuf> {
plan.tests
.iter()
.filter(|entry| {
let shown = entry.source.strip_prefix(project).unwrap_or(&entry.source);
filters.is_empty()
|| filters
.iter()
.any(|filter| shown.to_string_lossy().contains(filter.as_str()))
})
.map(|entry| entry.output.clone())
.collect()
}
pub fn burger_test_args(plan: &TestPlan, files: &[PathBuf], junit: Option<&Path>) -> Vec<OsString> {
let mut args: Vec<OsString> = vec![
"test".into(),
"--import-map".into(),
plan.import_map.clone().into(),
];
if let Some(junit) = junit {
args.push("--junit".into());
args.push(junit.into());
}
args.extend(files.iter().map(|file| file.clone().into_os_string()));
args
}
fn run_burger(project: &Path, filters: &[String], junit: Option<&Path>) -> Result<()> {
let burger = BurgerBinSearch::for_project(project).resolve()?;
let cli = burger_build_cli(project)?;
let has_tests_dir = project.join("tests").is_dir();
run_checked(
Command::new("bun").args(prepare_args(&cli, has_tests_dir)),
project,
"burger-build test-prepare",
)?;
let plan_path = project.join(BURGER_TEST_OUT).join("test-plan.json");
let plan = parse_test_plan(
&std::fs::read_to_string(&plan_path)
.with_context(|| format!("read {}", plan_path.display()))?,
)?;
let files = select_tests(&plan, project, filters);
if files.is_empty() {
bail!("{}", no_tests_message(has_tests_dir, filters));
}
println!("→ {} test ({} file(s))", burger.display(), files.len());
run_checked(
Command::new(&burger).args(burger_test_args(&plan, &files, junit)),
project,
"node-app-burger test",
)
}
fn run_checked(command: &mut Command, cwd: &Path, label: &str) -> Result<()> {
let status = command
.current_dir(cwd)
.status()
.with_context(|| format!("failed to start `{label}`"))?;
if !status.success() {
bail!("`{label}` failed ({status})");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn plan() -> TestPlan {
parse_test_plan(
r#"{
"importMap": "/app/target/burger-test/importmap.json",
"tests": [
{"source": "/app/src/db/db.test.ts", "output": "/app/target/burger-test/src/db/db.test.js"},
{"source": "/app/tests/nfc.test.ts", "output": "/app/target/burger-test/tests/nfc.test.js"}
]
}"#,
)
.unwrap()
}
#[test]
fn test_plan_matches_burger_build_output_shape() {
let plan = plan();
assert_eq!(
plan.import_map,
PathBuf::from("/app/target/burger-test/importmap.json")
);
assert_eq!(plan.tests.len(), 2);
assert_eq!(
plan.tests[1].source,
PathBuf::from("/app/tests/nfc.test.ts")
);
}
#[test]
fn filters_select_by_project_relative_source_path() {
let plan = plan();
let project = Path::new("/app");
assert_eq!(select_tests(&plan, project, &[]).len(), 2);
assert_eq!(
select_tests(&plan, project, &["nfc".into()]),
vec![PathBuf::from("/app/target/burger-test/tests/nfc.test.js")]
);
assert!(
select_tests(&plan, project, &["app".into()]).is_empty(),
"the project prefix itself never matches"
);
}
#[test]
fn burger_runner_arguments_follow_contract_c8() {
let plan = plan();
let files = vec![PathBuf::from("/app/target/burger-test/tests/nfc.test.js")];
assert_eq!(
burger_test_args(&plan, &files, Some(Path::new("junit.xml"))),
vec![
OsString::from("test"),
OsString::from("--import-map"),
OsString::from("/app/target/burger-test/importmap.json"),
OsString::from("--junit"),
OsString::from("junit.xml"),
OsString::from("/app/target/burger-test/tests/nfc.test.js"),
]
);
}
#[test]
fn prepare_arguments_include_tests_only_when_the_directory_exists() {
let cli = Path::new("/app/node_modules/@econ-v1/app-sdk/dist/burger-build/cli.js");
assert_eq!(
prepare_args(cli, true),
[
"/app/node_modules/@econ-v1/app-sdk/dist/burger-build/cli.js",
"test-prepare",
"--src",
"src",
"--tests",
"tests",
"--out",
"target/burger-test"
]
.map(OsString::from)
.to_vec()
);
assert!(!prepare_args(cli, false).contains(&OsString::from("--tests")));
}
#[test]
fn junit_paths_resolve_against_the_callers_directory() {
let cwd = Path::new("/work/ci");
assert_eq!(
resolve_from(cwd, Path::new("reports/junit.xml")),
PathBuf::from("/work/ci/reports/junit.xml")
);
assert_eq!(
resolve_from(cwd, Path::new("/abs/junit.xml")),
PathBuf::from("/abs/junit.xml")
);
}
#[test]
fn no_test_files_message_names_only_the_selected_patterns() {
assert_eq!(
no_tests_message(true, &[]),
"no test files (*.test.ts) under src/ or tests/"
);
assert_eq!(
no_tests_message(false, &[]),
"no test files (*.test.ts) under src/"
);
assert_eq!(
no_tests_message(true, &["db".into()]),
"no test files match [\"db\"]"
);
assert!(!no_tests_message(true, &[]).contains("spec"));
}
#[test]
fn bun_apps_keep_using_bun_test() {
assert_eq!(
bun_test_args(&["db".into()], None),
vec![OsString::from("test"), OsString::from("db")]
);
assert_eq!(
bun_test_args(&[], Some(Path::new("out/junit.xml"))),
vec![
OsString::from("test"),
OsString::from("--reporter=junit"),
OsString::from("--reporter-outfile=out/junit.xml"),
]
);
}
}