node-app-build 7.4.3

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `node-app dev` with UI-only apps (Contracts F5): the UI is built, the
//! manifest staged with a stamped `ui.integrity`, the staged copy linked where
//! the host reads apps, and nothing is started or sent over IPC. Each fixture
//! ships a prebuilt bundle and no `ui/package.json`, so the build step has
//! nothing to run and no bun is needed.

use assert_cmd::Command;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

const BUNDLE: &str = "export function mount() {}\n";

fn sha256_hex(bytes: &[u8]) -> String {
    format!("{:x}", Sha256::digest(bytes))
}

fn ui_only(root: &Path, manifest: &Value) {
    fs::create_dir_all(root.join("ui/dist")).unwrap();
    fs::write(root.join("manifest.json"), manifest.to_string()).unwrap();
    fs::write(root.join("ui/dist/main.js"), BUNDLE).unwrap();
}

fn stage(name: &str) -> Value {
    json!({
        "manifest_version": 2, "abi": "v1", "name": name, "version": "1.0.0",
        "ui_path": "ui/dist",
        "ui": {
            "kind": "stage", "entry": "ui/dist/main.js", "title": "Demo", "ui_api": 2,
            "requires": { "capabilities": [], "queries": [], "streams": [] },
            "integrity": {}
        }
    })
}

fn widget(name: &str) -> Value {
    json!({
        "manifest_version": 2, "abi": "v1", "name": name, "version": "1.0.0",
        "app_type": "ui-only", "has_ui": true, "ui_path": "ui/dist",
        "ui": {
            "kind": "widget", "entry": "ui/dist/main.js", "title": "Agent chat", "ui_api": 2,
            "requires": { "capabilities": ["agent.prompt"], "queries": [], "streams": [] },
            "integrity": {}
        }
    })
}

/// `node-app dev --once` against a socket path nothing listens on: any IPC
/// call would fail the run.
fn node_app_dev(project: &Path, dev_dir: &Path, deps: &[&Path]) -> Command {
    let lane = dev_dir.parent().unwrap();
    fs::create_dir_all(lane).unwrap();
    let socket = lane.join("control.sock");
    if !socket.exists() {
        fs::write(&socket, "").unwrap();
    }
    let mut command = Command::cargo_bin("node-app").unwrap();
    command
        .args(["dev", "--once", "--no-tui", "--path"])
        .arg(project)
        .arg("--socket")
        .arg(&socket)
        .arg("--dev-dir")
        .arg(dev_dir)
        .env_remove("NODE_DEV_APPS_DIR");
    for dep in deps {
        command.arg("--dep").arg(dep);
    }
    command
}

fn staged_entry_digest(staged: &Path) -> String {
    let manifest: Value =
        serde_json::from_str(&fs::read_to_string(staged.join("manifest.json")).unwrap()).unwrap();
    manifest["ui"]["integrity"]["ui/dist/main.js"]
        .as_str()
        .unwrap()
        .to_string()
}

/// A `node-app dev` lane beside `dev_dir`: its `daemon.env` names the
/// control socket and the apps directory (`modules/`) the host reads.
fn lane_env(dev_dir: &Path) -> PathBuf {
    let lane = dev_dir.parent().unwrap();
    let modules = lane.join("node/modules");
    fs::create_dir_all(&modules).unwrap();
    let socket = lane.join("control.sock");
    fs::write(&socket, "").unwrap();
    fs::write(
        lane.join("daemon.env"),
        format!(
            "APT_APPS_DIR={}\nNODE_DEV_APPS_DIR={}\nNODE_IPC_SOCKET={}\n",
            modules.display(),
            dev_dir.display(),
            socket.display()
        ),
    )
    .unwrap();
    modules
}

fn run(command: &mut Command) -> String {
    let output = command.output().unwrap();
    let log = format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(output.status.success(), "{log}");
    log
}

#[test]
fn dev_stages_a_ui_only_stage_and_a_nested_ui_only_widget_and_links_them_where_the_lane_reads_apps() {
    let work = tempfile::tempdir().unwrap();
    let work_root = work.path().canonicalize().unwrap();
    // A derived UI-only stage (no app_type, no entrypoint) as the primary app.
    let project = work_root.join("demo-stage");
    ui_only(&project, &stage("demo-stage"));
    // An explicit UI-only widget one level below another app's repo root,
    // like node-app-agent's chat-widget/.
    let repo = work_root.join("node-app-agent");
    fs::create_dir_all(&repo).unwrap();
    fs::write(
        repo.join("manifest.json"),
        r#"{"name":"agent","version":"1.0.0","app_type":"bun","entrypoint":"dist/index.js"}"#,
    )
    .unwrap();
    let nested = repo.join("chat-widget");
    ui_only(&nested, &widget("agent-chat"));
    let dev_dir = work_root.join("lane/dev-apps");
    let modules = lane_env(&dev_dir);
    let socket = dev_dir.parent().unwrap().join("control.sock");
    let lane_booted = |at: SystemTime| {
        fs::File::options().write(true).open(&socket).unwrap().set_modified(at).unwrap();
    };
    lane_booted(SystemTime::now() - Duration::from_secs(60));

    let log = run(&mut node_app_dev(&project, &dev_dir, &[&nested]));

    for name in ["demo-stage", "agent-chat"] {
        let staged = dev_dir.join(name);
        assert!(staged.join("ui/dist/main.js").is_file(), "{name} bundle staged\n{log}");
        assert_eq!(staged_entry_digest(&staged), sha256_hex(BUNDLE.as_bytes()), "{name}");
        assert!(!staged.join("dist").exists() && !staged.join("node_modules").exists());
        assert_eq!(
            modules.join(name).canonicalize().unwrap(),
            staged.canonicalize().unwrap(),
            "the lane host reads {name} through modules/\n{log}"
        );
    }
    assert!(!dev_dir.join("agent").exists(), "the repo root app is not staged");
    assert!(!log.contains("app.dev_load"), "no sideload attempted\n{log}");
    // The lane was already running: the links are new to it.
    assert!(log.contains("restart the lane to list and serve it"), "{log}");
    assert!(!log.contains("before boot"), "{log}");

    // After a lane restart, a rebuild is served as it is staged.
    lane_booted(SystemTime::now() + Duration::from_secs(60));
    let log = run(&mut node_app_dev(&project, &dev_dir, &[&nested]));
    assert!(log.contains("the host serves it from"), "{log}");
    assert!(!log.contains("restart the lane"), "{log}");
}

#[test]
fn dev_says_how_to_link_a_ui_only_app_when_the_hosts_apps_dir_is_unknown() {
    let work = tempfile::tempdir().unwrap();
    let project = work.path().join("demo-stage");
    ui_only(&project, &stage("demo-stage"));
    let dev_dir = work.path().join("lane/dev-apps");

    let log = run(&mut node_app_dev(&project, &dev_dir, &[]));

    assert!(dev_dir.join("demo-stage/ui/dist/main.js").is_file(), "{log}");
    assert!(log.contains("APT_APPS_DIR"), "{log}");
}

#[test]
fn dev_refuses_a_ui_only_app_that_declares_a_process_with_the_reason() {
    let work = tempfile::tempdir().unwrap();
    let project = work.path().join("agent-chat");
    let mut manifest = widget("agent-chat");
    manifest["entrypoint"] = json!("dist/index.js");
    ui_only(&project, &manifest);
    let dev_dir = work.path().join("lane/dev-apps");

    let output = node_app_dev(&project, &dev_dir, &[]).output().unwrap();
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(!output.status.success());
    assert!(stderr.contains("UI-only app 'agent-chat' cannot be loaded"), "{stderr}");
    assert!(stderr.contains("entrypoint"), "{stderr}");
    assert!(!dev_dir.join("agent-chat").exists());
}