node-app-build 6.4.3

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

fn fixture_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../examples/client-node-stage")
}

fn copy_tree(src: &Path, dst: &Path) {
    fs::create_dir_all(dst).unwrap();
    for entry in fs::read_dir(src).unwrap() {
        let entry = entry.unwrap();
        let source = entry.path();
        let dest = dst.join(entry.file_name());
        if entry.file_type().unwrap().is_dir() {
            copy_tree(&source, &dest);
        } else {
            fs::copy(&source, &dest).unwrap();
        }
    }
}

fn sha256_hex(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    format!("{:x}", hasher.finalize())
}

fn collect_relative_files(root: &Path) -> Vec<String> {
    fn walk(root: &Path, cursor: &Path, out: &mut Vec<String>) {
        for entry in fs::read_dir(cursor).unwrap() {
            let entry = entry.unwrap();
            let path = entry.path();
            if entry.file_type().unwrap().is_dir() {
                walk(root, &path, out);
            } else {
                let rel = path
                    .strip_prefix(root)
                    .unwrap()
                    .to_string_lossy()
                    .replace('\\', "/");
                out.push(rel);
            }
        }
    }

    let mut files = Vec::new();
    walk(root, root, &mut files);
    files.sort();
    files
}

fn normalized_stage_contract(path: &Path) -> String {
    let text = fs::read_to_string(path).unwrap();
    let start = text.find("export interface MountedStage").unwrap();
    text[start..]
        .lines()
        .filter(|line| {
            let trimmed = line.trim();
            !trimmed.is_empty()
                && !trimmed.starts_with("//")
                && !trimmed.starts_with("/**")
                && !trimmed.starts_with('*')
        })
        .collect::<String>()
        .chars()
        .filter(|ch| !ch.is_whitespace())
        .collect()
}

#[test]
fn package_stages_stage_ui_with_generated_integrity_and_no_typescript() {
    let fixture = fixture_root();
    let temp = tempfile::tempdir().unwrap();
    let project = temp.path().join("client-node-stage");
    copy_tree(&fixture, &project);

    let source_manifest_before = fs::read(project.join("manifest.json")).unwrap();
    let source_manifest_json: Value = serde_json::from_slice(&source_manifest_before).unwrap();
    let source_integrity = source_manifest_json["ui"].get("integrity");
    assert!(
        source_integrity.is_none()
            || source_integrity
                .and_then(|value| value.as_object())
                .map(|map| map.is_empty())
                .unwrap_or(false),
        "source manifest must not ship generated integrity: {source_manifest_json:#}"
    );

    Command::cargo_bin("node-app")
        .unwrap()
        .current_dir(temp.path())
        .args(["build", "--path"])
        .arg("client-node-stage")
        .assert()
        .success();

    let out_dir = temp.path().join("out");
    Command::cargo_bin("node-app")
        .unwrap()
        .current_dir(temp.path())
        .env("NODE_APP_PACKAGE_STAGE_ONLY", "1")
        .args(["package", "--path"])
        .arg("client-node-stage")
        .args(["--out"])
        .arg(&out_dir)
        .assert()
        .success();

    assert_eq!(source_manifest_before, fs::read(project.join("manifest.json")).unwrap());

    let app_root = out_dir.join("staging/usr/lib/node/apps/client-node-stage");
    Command::cargo_bin("node-app")
        .unwrap()
        .args(["validate", "--path"])
        .arg(&app_root)
        .assert()
        .success();
    let staged_manifest: Value =
        serde_json::from_slice(&fs::read(app_root.join("manifest.json")).unwrap()).unwrap();
    let integrity: BTreeMap<String, String> = serde_json::from_value(
        staged_manifest["ui"]["integrity"].clone(),
    )
    .unwrap();

    let staged_files = collect_relative_files(&app_root);
    assert!(
        staged_files.iter().all(|path| !path.ends_with(".ts")),
        "staged files should not contain TypeScript sources: {staged_files:?}"
    );
    assert!(
        integrity.len() >= 2,
        "expected entry/icon integrity plus any emitted assets, got {integrity:?}"
    );

    for (relative_path, expected_digest) in &integrity {
        let bytes = fs::read(app_root.join(relative_path)).unwrap();
        assert_eq!(
            sha256_hex(&bytes),
            *expected_digest,
            "digest mismatch for {relative_path}"
        );
    }

    let entry_path = staged_manifest["ui"]["entry"].as_str().unwrap();
    let mut mutated = fs::read(app_root.join(entry_path)).unwrap();
    mutated[0] ^= 0b0000_0001;
    assert_ne!(
        sha256_hex(&mutated),
        integrity.get(entry_path).unwrap().as_str(),
        "mutating one byte should invalidate the published digest"
    );
}

#[test]
fn local_stage_context_shims_match_the_rpc_stage_contract_surface() {
    let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
    let rpc_contract =
        normalized_stage_contract(&repo_root.join("client/rpc/src/stage-context.ts"));
    let example_contract = normalized_stage_contract(
        &repo_root.join("examples/client-node-stage/ui/src/vendor-node-client-rpc-stage-context.d.ts"),
    );
    let profile_contract = normalized_stage_contract(
        &repo_root.join(
            "system/node-app-build/profiles/bun-stage/ui/src/vendor-node-client-rpc-stage-context.d.ts",
        ),
    );

    assert_eq!(example_contract, rpc_contract);
    assert_eq!(profile_contract, rpc_contract);
}