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": {}
}
})
}
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()
}
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();
let project = work_root.join("demo-stage");
ui_only(&project, &stage("demo-stage"));
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}");
assert!(log.contains("restart the lane to list and serve it"), "{log}");
assert!(!log.contains("before boot"), "{log}");
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());
}