use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
static INSTALL_LOCK: Mutex<()> = Mutex::new(());
struct Fixture {
root: PathBuf,
frontend: PathBuf,
}
impl Fixture {
fn create() -> Self {
let root = std::env::temp_dir().join(format!(
"arcature-install-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
));
fs::create_dir_all(root.join("frontend")).expect("frontend should be created");
fs::create_dir_all(root.join("bin")).expect("bin should be created");
fs::write(
root.join("arcature.toml"),
"frontend = \"react\"\nfrontend_dir = \"frontend\"\nbackend_package = \"demo\"\nbackend_binary = \"demo\"\nbackend_port = 3000\n",
)
.expect("config should be written");
fs::write(
root.join("frontend/package.json"),
"{\n \"name\": \"demo\",\n \"dependencies\": {}\n}\n",
)
.expect("package.json should be written");
write_fake_pnpm(&root);
write_fake_node(&root);
Self {
root: root.clone(),
frontend: root.join("frontend"),
}
}
fn path(&self) -> String {
let bin = self.root.join("bin");
let separator = if cfg!(windows) { ";" } else { ":" };
format!(
"{}{}{}",
bin.display(),
separator,
std::env::var("PATH").unwrap_or_default()
)
}
fn install(&self) -> std::process::ExitStatus {
Command::new(env!("CARGO_BIN_EXE_arc"))
.arg("install")
.current_dir(&self.root)
.env("PATH", self.path())
.output()
.expect("arc install should run")
.status
}
fn install_output(&self, env: &[(&str, &str)]) -> (std::process::ExitStatus, String) {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_arc"));
cmd.arg("install")
.current_dir(&self.root)
.env("PATH", self.path());
for (k, v) in env {
cmd.env(k, v);
}
let output = cmd.output().expect("arc install should run");
(
output.status,
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
fn pnpm_log(&self) -> Vec<String> {
match fs::read_to_string(self.frontend.join("pnpm.log")) {
Ok(text) => text.lines().map(String::from).collect(),
Err(_) => Vec::new(),
}
}
fn lockfile_exists(&self) -> bool {
self.frontend.join("pnpm-lock.yaml").is_file()
}
fn cleanup(self) {
remove_dir_all_resilient(&self.root);
}
}
fn remove_dir_all_resilient(path: &Path) {
for attempt in 0..5 {
match fs::remove_dir_all(path) {
Ok(()) => return,
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
std::thread::sleep(std::time::Duration::from_millis(50 * 2_u64.pow(attempt)));
}
Err(error) => {
eprintln!(
"warn install_lifecycle cleanup failed for {}: {error}",
path.display()
);
return;
}
}
}
eprintln!(
"warn install_lifecycle cleanup gave up after 5 retries for {}",
path.display()
);
}
#[test]
fn first_run_without_lockfile_runs_normal_install() {
let _guard = INSTALL_LOCK.lock().expect("install test lock");
let fixture = Fixture::create();
assert!(!fixture.lockfile_exists(), "no lockfile before first run");
let status = fixture.install();
assert!(status.success(), "first install should succeed");
let log = fixture.pnpm_log();
assert!(
log.iter()
.any(|line| line.contains("install") && !line.contains("--frozen-lockfile")),
"first run should invoke `pnpm install` without --frozen-lockfile, got: {log:?}"
);
fixture.cleanup();
}
#[test]
fn first_preparation_generates_lockfile() {
let _guard = INSTALL_LOCK.lock().expect("install test lock");
let fixture = Fixture::create();
assert!(!fixture.lockfile_exists(), "no lockfile before first run");
let status = fixture.install();
assert!(status.success(), "first install should succeed");
assert!(
fixture.lockfile_exists(),
"lockfile should exist after first install"
);
fixture.cleanup();
}
#[test]
fn second_unchanged_startup_skips_install() {
let _guard = INSTALL_LOCK.lock().expect("install test lock");
let fixture = Fixture::create();
let first = fixture.install();
assert!(first.success(), "first install should succeed");
let log_after_first = fixture.pnpm_log();
let invocations_after_first = log_after_first
.iter()
.filter(|line| line.contains("install"))
.count();
let second = fixture.install();
assert!(second.success(), "second install should succeed");
let log_after_second = fixture.pnpm_log();
let invocations_after_second = log_after_second
.iter()
.filter(|line| line.contains("install"))
.count();
assert_eq!(
invocations_after_first, invocations_after_second,
"second unchanged startup should NOT invoke pnpm install (skipped by fingerprint)"
);
fixture.cleanup();
}
#[test]
fn changed_package_json_triggers_preparation() {
let _guard = INSTALL_LOCK.lock().expect("install test lock");
let fixture = Fixture::create();
let first = fixture.install();
assert!(first.success(), "first install should succeed");
fs::write(
fixture.frontend.join("package.json"),
"{\n \"name\": \"demo\",\n \"dependencies\": { \"lodash\": \"4.17.21\" }\n}\n",
)
.expect("package.json should be rewritten");
let second = fixture.install();
assert!(second.success(), "second install should succeed");
let log = fixture.pnpm_log();
let install_count = log.iter().filter(|line| line.contains("install")).count();
assert!(
install_count >= 2,
"changed package.json should trigger a second install invocation, got {install_count}"
);
fixture.cleanup();
}
#[test]
fn changed_lockfile_triggers_preparation() {
let _guard = INSTALL_LOCK.lock().expect("install test lock");
let fixture = Fixture::create();
let first = fixture.install();
assert!(first.success(), "first install should succeed");
assert!(
fixture.lockfile_exists(),
"lockfile should exist after first install"
);
fs::write(
fixture.frontend.join("pnpm-lock.yaml"),
"# modified lockfile\nlockfileVersion: 5.4\n",
)
.expect("lockfile should be rewritten");
let second = fixture.install();
assert!(second.success(), "second install should succeed");
let log = fixture.pnpm_log();
let frozen_count = log
.iter()
.filter(|line| line.contains("install") && line.contains("--frozen-lockfile"))
.count();
assert!(
frozen_count >= 1,
"changed lockfile should trigger a frozen install, got {frozen_count} frozen invocations"
);
fixture.cleanup();
}
#[test]
fn frozen_install_failure_produces_actionable_diagnostic() {
let _guard = INSTALL_LOCK.lock().expect("install test lock");
let fixture = Fixture::create();
fs::write(
fixture.frontend.join("pnpm-lock.yaml"),
"# stale lockfile\n",
)
.expect("lockfile should be written");
let (status, stderr) = fixture.install_output(&[("ARC_FIXTURE_PNPM_FAIL_FROZEN", "1")]);
assert!(
!status.success(),
"frozen install failure should exit non-zero"
);
assert!(
stderr.contains("hint") && stderr.contains("pnpm install") && stderr.contains("regenerate"),
"frozen install failure should include an actionable hint mentioning \
regenerating the lockfile, got:\n{stderr}"
);
fixture.cleanup();
}
#[cfg(unix)]
fn write_fake_pnpm(root: &Path) {
use std::os::unix::fs::PermissionsExt;
let path = root.join("bin/pnpm");
fs::write(
&path,
"#!/bin/sh\necho \"$@\" >> pnpm.log\nif [ \"$1\" = \"--version\" ]; then echo '11.20.0'; exit 0; fi\nif [ \"$1\" = \"install\" ]; then\n if [ \"$ARC_FIXTURE_PNPM_FAIL_FROZEN\" = \"1\" ] && [ \"$2\" = \"--frozen-lockfile\" ]; then\n echo 'lockfile out of sync' >&2\n exit 1\n fi\n if [ \"$2\" != \"--frozen-lockfile\" ] && [ ! -f pnpm-lock.yaml ]; then\n echo '# generated lockfile' > pnpm-lock.yaml\n fi\nfi\nexit 0\n",
)
.expect("fake pnpm should be written");
let mut permissions = fs::metadata(&path)
.expect("fixture metadata should exist")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&path, permissions).expect("fixture should be executable");
}
#[cfg(windows)]
fn write_fake_pnpm(root: &Path) {
fs::write(
root.join("bin/pnpm.cmd"),
"@echo off\r\necho %* >> pnpm.log\r\nif \"%~1\"==\"--version\" (echo 11.20.0 & exit /b 0)\r\nif not \"%~1\"==\"install\" exit /b 0\r\nif \"%ARC_FIXTURE_PNPM_FAIL_FROZEN%\"==\"1\" if \"%~2\"==\"--frozen-lockfile\" goto :frozen_fail\r\nif not \"%~2\"==\"--frozen-lockfile\" if not exist pnpm-lock.yaml (\r\n echo # generated lockfile > pnpm-lock.yaml\r\n)\r\nexit /b 0\r\n:frozen_fail\r\necho lockfile out of sync 1>&2\r\nexit /b 1\r\n",
)
.expect("fake pnpm.cmd should be written");
}
#[cfg(unix)]
fn write_fake_node(root: &Path) {
use std::os::unix::fs::PermissionsExt;
let path = root.join("bin/node");
fs::write(
&path,
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'v24.19.0'; exit 0; fi\nexit 0\n",
)
.expect("fake node should be written");
let mut permissions = fs::metadata(&path)
.expect("fixture metadata should exist")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&path, permissions).expect("fixture should be executable");
}
#[cfg(windows)]
fn write_fake_node(root: &Path) {
fs::write(
root.join("bin/node.cmd"),
"@echo off\r\nif \"%~1\"==\"--version\" (echo v24.19.0 & exit /b 0)\r\nexit /b 0\r\n",
)
.expect("fake node.cmd should be written");
}