node-app-build 7.1.6

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `node-app test` — run an app's tests in the runtime it ships on.
//!
//! - `app_type: "bun"`: `bun test`.
//! - `app_type: "burger"`: `burger-build test-prepare` turns `src/` (and
//!   `tests/`) into per-file ES modules plus `importmap.json`, then
//!   `node-app-burger test --import-map <map> <files>` runs them in QuickJS
//!   (Contract C8). Exit status is the runner's.

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;

/// Where prepared Burger test modules are written, relative to the project.
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>,
}

/// One entry of `<out>/test-plan.json`, written by `burger-build test-prepare`.
#[derive(Debug, Deserialize, PartialEq, Eq)]
pub struct TestPlanEntry {
    pub source: PathBuf,
    pub output: PathBuf,
}

/// `<out>/test-plan.json`, written by `burger-build test-prepare`.
#[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()))?;
    // The runners start in the project directory; `--junit` is the caller's path.
    let cwd = std::env::current_dir().context("read the current directory")?;
    let junit = args.junit.as_deref().map(|junit| resolve_from(&cwd, junit));
    // Neither runner creates the report's directory.
    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:?})")
        }
    }
}

/// `path` as the caller meant it: relative paths are taken from `cwd`.
pub fn resolve_from(cwd: &Path, path: &Path) -> PathBuf {
    if path.is_absolute() {
        path.to_path_buf()
    } else {
        cwd.join(path)
    }
}

/// Why `node-app test` found nothing to run, naming only what test-prepare selects.
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")
}

/// Prepared files to run: every test when `filters` is empty, otherwise the
/// tests whose source path relative to `project` contains any filter
/// (the same rule as `bun test <filter>`).
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()
}

/// `node-app-burger test --import-map <map> [--junit <path>] <files…>` (C8).
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"),
            ]
        );
    }
}