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()
);
}
}
}