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