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 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 balanced_braced_segment(text: &str, open_index: usize) -> (&str, usize) {
    let mut depth = 0_u32;
    for (offset, character) in text[open_index..].char_indices() {
        match character {
            '{' => depth += 1,
            '}' => {
                depth = depth
                    .checked_sub(1)
                    .expect("closing brace must follow an opening brace");
                if depth == 0 {
                    let end = open_index + offset + character.len_utf8();
                    return (&text[open_index..end], end);
                }
            }
            _ => {}
        }
    }
    panic!("unclosed type expression starting at byte {open_index}");
}

fn jsdoc_type_expression(path: &Path, type_name: &str) -> String {
    let text = fs::read_to_string(path).unwrap();
    for (marker_index, _) in text.match_indices("@typedef {") {
        let open_index = marker_index + "@typedef ".len();
        let (expression, end) = balanced_braced_segment(&text, open_index);
        let declared_name = text[end..]
            .trim_start_matches(|character: char| {
                character.is_whitespace() || character == '*' || character == '/'
            })
            .split_whitespace()
            .next();
        if declared_name == Some(type_name) {
            let mut uncommented = String::new();
            for line in expression[1..expression.len() - 1].lines() {
                let trimmed = line.trim_start();
                uncommented.push_str(trimmed.strip_prefix('*').unwrap_or(trimmed));
            }
            return canonical_type_expression(&uncommented);
        }
    }
    panic!(
        "missing JSDoc typedef {type_name} in {}",
        path.to_string_lossy()
    );
}

fn declaration_type_expression(path: &Path, type_name: &str) -> String {
    let text = fs::read_to_string(path).unwrap();
    let interface_marker = format!("export interface {type_name}");
    if let Some((marker_index, _)) = text
        .match_indices(&interface_marker)
        .find(|(marker_index, _)| {
            matches!(
                text[marker_index + interface_marker.len()..].chars().next(),
                Some(character) if character.is_whitespace() || character == '{'
            )
        })
    {
        let open_index = text[marker_index + interface_marker.len()..]
            .find('{')
            .map(|offset| marker_index + interface_marker.len() + offset)
            .unwrap_or_else(|| panic!("missing body for {interface_marker}"));
        let (expression, _) = balanced_braced_segment(&text, open_index);
        return canonical_type_expression(expression);
    }

    let type_marker = format!("export type {type_name} =");
    if let Some(marker_index) = text.find(&type_marker) {
        let expression_start = marker_index + type_marker.len();
        let mut braces = 0_u32;
        let mut brackets = 0_u32;
        let mut parentheses = 0_u32;
        for (offset, character) in text[expression_start..].char_indices() {
            match character {
                '{' => braces += 1,
                '}' => braces = braces.checked_sub(1).expect("balanced type braces"),
                '[' => brackets += 1,
                ']' => brackets = brackets.checked_sub(1).expect("balanced type brackets"),
                '(' => parentheses += 1,
                ')' => {
                    parentheses = parentheses
                        .checked_sub(1)
                        .expect("balanced type parentheses")
                }
                ';' if braces == 0 && brackets == 0 && parentheses == 0 => {
                    let end = expression_start + offset;
                    return canonical_type_expression(&text[expression_start..end]);
                }
                _ => {}
            }
        }
        panic!("unterminated declaration for {type_marker}");
    }

    panic!(
        "missing exported declaration {type_name} in {}",
        path.to_string_lossy()
    );
}

fn canonical_type_expression(expression: &str) -> String {
    expression
        .replace("import(\"@econ-v1/domain\").", "")
        .replace("import(\"./client.js\").", "")
        .chars()
        .filter(|character| !character.is_whitespace() && *character != ',' && *character != ';')
        .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");
    let triggers = fs::read_to_string(out_dir.join("staging/DEBIAN/triggers")).unwrap();
    assert_eq!(triggers, "activate-noawait /usr/share/node/triggers/apps\n");
    assert!(!triggers.contains("interest-noawait"));
    assert!(!out_dir.join("staging/DEBIAN/triggered").exists());
    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 = repo_root.join("client/rpc/src/stage-context.js");
    let declaration_shims = [
        repo_root.join(
            "examples/client-node-stage/ui/src/vendor-node-client-rpc-stage-context.d.ts",
        ),
        repo_root.join(
            "system/node-app-build/profiles/stage/ui/src/vendor-node-client-rpc-stage-context.d.ts",
        ),
        repo_root.join(
            "system/node-app-build/profiles/bun-stage/ui/src/vendor-node-client-rpc-stage-context.d.ts",
        ),
    ];

    for type_name in [
        "MountedStage",
        "StageLoadHandler",
        "StageContext",
        "StageModule",
    ] {
        let expected = jsdoc_type_expression(&rpc_contract, type_name);
        for declaration_shim in &declaration_shims {
            assert_eq!(
                declaration_type_expression(declaration_shim, type_name),
                expected,
                "{type_name} in {} must match the RPC JSDoc contract",
                declaration_shim.to_string_lossy()
            );
        }
    }
}