node-app-build 6.11.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
use assert_cmd::Command;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};

fn scaffold_profile(root: &Path, profile: &str, name: &str) -> PathBuf {
    let project = root.join(name);
    Command::cargo_bin("node-app")
        .unwrap()
        .args(["new", name, "--profile", profile, "--out"])
        .arg(&project)
        .args(["--yes"])
        .assert()
        .success();
    project
}

fn read_manifest(project: &Path) -> Value {
    serde_json::from_slice(&fs::read(project.join("manifest.json")).unwrap()).unwrap()
}

fn stage_minimum_build_outputs(project: &Path) {
    let manifest = read_manifest(project);
    let name = manifest["name"].as_str().unwrap();
    match manifest["app_type"].as_str().unwrap() {
        "bun" => {
            let dist = project.join("dist");
            fs::create_dir_all(&dist).unwrap();
            fs::write(dist.join("index.js"), "console.log('ok');\n").unwrap();
        }
        "native" => {
            let entrypoint = manifest["entrypoint"].as_str().unwrap();
            let built = project.join("target/node-app/amd64");
            fs::create_dir_all(&built).unwrap();
            fs::write(built.join(entrypoint), b"\x7fELF-fake").unwrap();
            let workflows = project.join(".github/workflows");
            fs::create_dir_all(&workflows).unwrap();
            fs::write(
                workflows.join("release.yml"),
                "name: release\njobs:\n  sign:\n    runs-on: ubuntu-latest\n    steps:\n      - run: gpg --detach-sign manifest.json # manifest sidecar\n      - run: echo SIDECAR\n",
            )
            .unwrap();
        }
        "standalone" => {
            let built = project.join("target/node-app/amd64");
            fs::create_dir_all(&built).unwrap();
            fs::write(built.join(format!("node-app-{name}")), b"\x7fELF-fake").unwrap();
        }
        other => panic!("unexpected app_type {other}"),
    }

    if manifest["has_ui"].as_bool().unwrap_or(false) {
        let ui_root = project.join(manifest["ui_path"].as_str().unwrap_or("ui/dist"));
        fs::create_dir_all(&ui_root).unwrap();
        fs::write(
            ui_root.join("index.html"),
            "<!doctype html><title>ok</title>\n",
        )
        .unwrap();
        if let Some(entry) = manifest
            .get("ui")
            .and_then(|ui| ui.get("entry"))
            .and_then(|value| value.as_str())
        {
            let entry_path = project.join(entry);
            if let Some(parent) = entry_path.parent() {
                fs::create_dir_all(parent).unwrap();
            }
            fs::write(entry_path, "console.log('ui ok');\n").unwrap();
        }
        if let Some(icon) = manifest
            .get("ui")
            .and_then(|ui| ui.get("icon"))
            .and_then(|value| value.as_str())
        {
            let icon_path = project.join(icon);
            if let Some(parent) = icon_path.parent() {
                fs::create_dir_all(parent).unwrap();
            }
            fs::write(
                icon_path,
                "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>\n",
            )
            .unwrap();
        }
    }
}

fn stage_package(project: &Path, arch: &str, out: &Path) {
    Command::cargo_bin("node-app")
        .unwrap()
        .current_dir(project)
        .env("NODE_APP_PACKAGE_STAGE_ONLY", "1")
        .args(["package", "--arch", arch, "--out"])
        .arg(out)
        .assert()
        .success();
}

fn read_trigger_file(staging_root: &Path) -> Option<String> {
    fs::read_to_string(staging_root.join("DEBIAN/triggers")).ok()
}

#[test]
fn package_triggers_platform_loaded_profiles_emit_activate_noawait_only() {
    let temp = tempfile::tempdir().unwrap();
    for (profile, arch) in [
        ("bun", "all"),
        ("bun-stage", "all"),
        ("bun-fullstack", "all"),
        ("native", "amd64"),
        ("native-fullstack", "amd64"),
    ] {
        let name = format!("trigger-{}", profile.replace('-', "-x-"));
        let project = scaffold_profile(temp.path(), profile, &name);
        stage_minimum_build_outputs(&project);
        let out = project.join("target/test-package-triggers");
        stage_package(&project, arch, &out);

        let trigger_text = read_trigger_file(&out.join("staging"))
            .unwrap_or_else(|| format!("missing DEBIAN/triggers for profile {profile}"));
        assert_eq!(
            trigger_text, "activate-noawait /usr/share/node/triggers/apps\n",
            "unexpected trigger contract for profile {profile}"
        );
        assert!(
            !trigger_text.contains("interest-noawait"),
            "profile {profile} must not declare interest-noawait:\n{trigger_text}"
        );
        assert!(
            !trigger_text.contains("dpkg-trigger"),
            "profile {profile} must not shell out to dpkg-trigger:\n{trigger_text}"
        );
        assert!(
            !out.join("staging/DEBIAN/triggered").exists(),
            "profile {profile} must not stage a DEBIAN/triggered callback"
        );
    }
}

#[test]
fn package_triggers_standalone_profiles_remain_trigger_free() {
    let temp = tempfile::tempdir().unwrap();
    for profile in ["standalone-bun", "standalone-native"] {
        let name = format!("trigger-{}", profile.replace('-', "-x-"));
        let project = scaffold_profile(temp.path(), profile, &name);
        stage_minimum_build_outputs(&project);
        let out = project.join("target/test-package-triggers");
        stage_package(&project, "amd64", &out);

        assert!(
            read_trigger_file(&out.join("staging")).is_none(),
            "standalone profile {profile} must not stage DEBIAN/triggers"
        );
    }
}