arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! Lockfile-aware frontend dependency preparation tests (AP2.1-4 CI
//! correction §3).
//!
//! Each test states the invariant it proves (AGENTS.md §28). The tests use a
//! fake `pnpm` on `PATH` that records each invocation's args to `pnpm.log` and
//! simulates first-install lockfile creation. The fake `pnpm` is a shell
//! script on Unix and a batch file on Windows — the lockfile logic under test
//! is cross-platform (pure `Path` APIs), so the tests run on both.
//!
//! The tests are serialized via [`INSTALL_LOCK`] because each spawns a real
//! `arc install` subprocess (which itself spawns `pnpm.cmd`/`pnpm`). On
//! Windows, `cmd.exe` process-handle release can race with parallel test
//! execution, producing intermittent "file in use" failures on
//! `node_modules/` cleanup and spurious install failures. Serializing 6 fast
//! tests (~2s total) eliminates the race with no meaningful performance cost.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;

/// Serializes the install tests so parallel `arc install` subprocesses do not
/// race on Windows process-handle release. Acquired by each test via
/// [`INSTALL_LOCK`](self::INSTALL_LOCK) at the start.
static INSTALL_LOCK: Mutex<()> = Mutex::new(());

/// A test fixture: a project root with `arcature.toml`, a `frontend/` with
/// `package.json`, and a fake `pnpm` on `PATH` that logs invocations and
/// simulates lockfile creation.
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");
        // On Windows, PATH entries are separated by `;`, on Unix by `:`.
        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(),
        )
    }

    /// Reads the pnpm invocation log from the frontend directory (where
    /// `pnpm install` runs). The `pnpm --version` probe runs from the
    /// project root, so its log entry goes to a different file and does not
    /// pollute this log.
    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) {
        // On Windows, `remove_dir_all` can transiently fail with "file in use"
        // (Os code 32) if a subprocess handle is still releasing. Retry with
        // a short backoff; if it ultimately fails, leak the temp dir (the OS
        // temp dir is periodically cleaned) rather than masking a real test
        // result with an environmental cleanup failure.
        remove_dir_all_resilient(&self.root);
    }
}

/// Removes a directory tree, retrying on Windows if a file is transiently
/// locked by a releasing subprocess handle. After a bounded number of
/// retries, gives up (leaks the temp dir) instead of panicking — the test's
/// invariant is about install behavior, not temp-dir cleanup.
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()
    );
}

// ── §3 Test 1: new project without lockfile ──────────────────────────────

/// Invariant: a newly generated project with no `pnpm-lock.yaml` runs
/// `pnpm install` (NOT `--frozen-lockfile`) on the first dependency
/// preparation. The frozen-lockfile flag would fail because there is no
/// lockfile to freeze against (the root cause of the original CI failure).
#[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();
}

// ── §3 Test 2: lockfile generated on first dependency preparation ────────

/// Invariant: the first dependency preparation creates `pnpm-lock.yaml`.
/// After the first `arc install`, a lockfile exists (created by the
/// installer), so subsequent runs can use `--frozen-lockfile`.
#[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();
}

// ── §3 Test 3: second unchanged startup skips install ─────────────────────

/// Invariant: after a successful install, a second `arc install` with
/// unchanged `package.json` + lockfile does NOT invoke `pnpm install` at all
/// — the "do not run pnpm install on every `arc dev`" invariant (§3).
#[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();
    // Second install — unchanged.
    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();
}

// ── §3 Test 4: changed package.json triggers preparation ─────────────────

/// Invariant: after a successful install, changing `package.json` invalidates
/// the fingerprint and the next `arc install` re-runs `pnpm install`.
#[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");
    // Change package.json — invalidate the fingerprint.
    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();
}

// ── §3 Test 5: changed lockfile triggers preparation ──────────────────────

/// Invariant: after a successful install, changing `pnpm-lock.yaml`
/// invalidates the fingerprint and the next `arc install` re-runs `pnpm
/// install` (with `--frozen-lockfile`, since the lockfile now exists).
#[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"
    );
    // Change the lockfile — invalidate the fingerprint.
    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();
}

// ── §3 Test 6: frozen install failure produces actionable diagnostic ─────

/// Invariant: when `pnpm install --frozen-lockfile` fails (the lockfile is
/// out of sync with package.json), the error output includes an actionable
/// hint telling the user how to recover (regenerate the lockfile). This is
/// the root-cause diagnostic for the original CI failure where
/// `--frozen-lockfile` was used unconditionally on a project with no
/// lockfile.
#[test]
fn frozen_install_failure_produces_actionable_diagnostic() {
    let _guard = INSTALL_LOCK.lock().expect("install test lock");
    let fixture = Fixture::create();
    // Seed a lockfile so the frozen path is taken, then make pnpm fail on
    // --frozen-lockfile (simulating an out-of-sync lockfile).
    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();
}

// ── Fake executable helpers (cross-platform) ─────────────────────────────

/// Writes the fake `pnpm` to `bin/`. On Unix, a shell script `bin/pnpm`; on
/// Windows, a batch file `bin/pnpm.cmd` (matching `Tool::Pnpm.executable()`
/// which returns `"pnpm.cmd"` on Windows).
#[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");
}

/// Writes the fake `pnpm` to `bin/pnpm.cmd` on Windows. Uses `goto` labels to
/// exit on the frozen-failure path — `exit /b N` inside a nested
/// parenthesized `if` block does not reliably propagate the exit code in
/// cmd.exe; `goto` to a label outside any block is the standard batch idiom.
/// `pnpm.log` is written in the CWD (frontend/ for install invocations,
/// project root for the --version probe).
#[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");
}

/// Writes the fake `node` to `bin/`. On Unix, a shell script `bin/node`; on
/// Windows, a batch file `bin/node.cmd`.
#[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");
}