From eb6a909daaec668c7fdc3cf10b8c1c3420a9b216 Mon Sep 17 00:00:00 2001 From: Calvin Grunewald Date: Thu, 15 Oct 2026 12:44:08 -0700 Subject: [PATCH] factory: Integration test proves identical keys across checkout paths and machines --- tests/cache_portability.rs | 292 +++++++++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 tests/cache_portability.rs diff --git a/tests/cache_portability.rs b/tests/cache_portability.rs new file mode 100644 index 0000000..1635684 --- /dev/null +++ b/tests/cache_portability.rs @@ -0,0 +1,292 @@ +//! Cache keys are identical across checkout paths and machines. +//! +//! Two copies of one fixture live at different absolute paths, with different +//! `HOME` values and `CI` set in only one. Every cacheable target must hash +//! to the same key. Absolute paths, `HOME`, `CI`, and gitignored directories +//! must not leak into a key. + +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; +use std::process::Command; + +use serde_json::{json, Value}; +use tempfile::TempDir; + +const COMPONENT_FIELDS: &[&str] = &[ + "salt", "platform", "files", "command", "deps", "env", "path_env", "runtime", +]; + +/// Build the acceptance fixture under `root`. +/// +/// `extra_ignored` adds `node_modules/` and `target/` content that `.gitignore` +/// excludes. Those directories must not change a cache key. +fn make_fixture(root: &Path, extra_ignored: bool) { + fs::create_dir_all(root.join(".git")).unwrap(); + write(root, ".gitignore", "node_modules/\ntarget/\n"); + + write( + root, + "apps/web/package.json", + "{\n \"name\": \"web\",\n \"scripts\": {\n \"build\": \"echo build\",\n \"test\": \"echo test\"\n }\n}\n", + ); + write( + root, + "apps/web/src/index.js", + "export const ready = true;\n", + ); + write( + root, + "apps/web/aster.toml", + &cache_runtime_toml(&[ + ("deps", "npm install"), + ("build", "npm run build"), + ("test", "npm test"), + ]), + ); + + write( + root, + "Cargo.toml", + "[workspace]\nmembers = [\"crates/a\", \"crates/b\"]\nresolver = \"2\"\n", + ); + write( + root, + "Cargo.lock", + "# This file is automatically @generated by Cargo.\nversion = 3\n", + ); + for crate_name in ["a", "b"] { + write( + root, + &format!("crates/{crate_name}/Cargo.toml"), + &format!( + "[package]\nname = \"{crate_name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n" + ), + ); + write( + root, + &format!("crates/{crate_name}/src/lib.rs"), + "pub fn ready() -> bool { true }\n", + ); + write( + root, + &format!("crates/{crate_name}/aster.toml"), + &cache_runtime_toml(&[ + ("deps", "cargo fetch"), + ("build", "cargo build"), + ("test", "cargo test"), + ("lint", "cargo clippy"), + ("format", "cargo fmt"), + ("check", "cargo check"), + ]), + ); + } + + write( + root, + "services/api/go.mod", + "module example.com/api\n\ngo 1.22\n", + ); + write( + root, + "services/api/main.go", + "package main\n\nfunc main() {}\n", + ); + write( + root, + "services/api/aster.toml", + &cache_runtime_toml(&[ + ("deps", "go mod download"), + ("build", "go build ./..."), + ("test", "go test ./..."), + ("format", "go fmt ./..."), + ]), + ); + + if extra_ignored { + write(root, "node_modules/x/index.js", "module.exports = 1;\n"); + write( + root, + "apps/web/node_modules/x/index.js", + "module.exports = 2;\n", + ); + write(root, "target/debug/junk", "not a real artifact\n"); + write(root, "crates/a/target/debug/junk", "member junk\n"); + } +} + +fn cache_runtime_toml(targets: &[(&str, &str)]) -> String { + let mut toml = String::new(); + for (target, command) in targets { + toml.push_str(&format!( + "[targets.{target}]\ncommand = \"{command}\"\ncache = {{ runtime = [\"echo fixed\"] }}\n\n" + )); + } + toml +} + +fn write(root: &Path, path: &str, content: &str) { + let full = root.join(path); + fs::create_dir_all(full.parent().unwrap()).unwrap(); + fs::write(full, content).unwrap(); +} + +fn aster(root: &Path, home: &Path, ci: bool) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_aster")); + cmd.current_dir(root) + .env_clear() + .env("HOME", home) + .env("PATH", std::env::var_os("PATH").unwrap_or_default()); + if ci { + cmd.env("CI", "true"); + } + cmd +} + +fn list_addresses(root: &Path, home: &Path, ci: bool) -> Vec { + let output = aster(root, home, ci) + .args(["list", "--json"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "aster list failed: {} {stdout}", + String::from_utf8_lossy(&output.stderr) + ); + let projects: Vec = serde_json::from_str(&stdout).expect("list json"); + let mut addresses = Vec::new(); + for project in projects { + let project_addr = project["address"].as_str().expect("address"); + let targets = project["targets"].as_object().expect("targets"); + for name in targets.keys() { + addresses.push(format!("{project_addr}:{name}")); + } + } + addresses.sort(); + addresses +} + +fn cache_inputs(root: &Path, home: &Path, ci: bool, address: &str) -> Result { + let output = aster(root, home, ci) + .args(["cache", "status", address, "--inputs", "--json"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + if !output.status.success() { + return Err(format!("{address}: {stderr} {stdout}")); + } + serde_json::from_str(&stdout).map_err(|err| format!("{address}: {err}: {stdout}")) +} + +fn component_diff(left: &Value, right: &Value) -> Value { + let mut diff = serde_json::Map::new(); + for field in COMPONENT_FIELDS { + if left.get(*field) != right.get(*field) { + diff.insert( + (*field).to_string(), + json!({ "a": left.get(*field), "b": right.get(*field) }), + ); + } + } + Value::Object(diff) +} + +/// Two checkouts of one fixture, at different absolute paths and with +/// different `HOME`, produce the same cache key for every cacheable target. +/// One copy has `CI=true` and extra gitignored directories; neither may leak. +#[test] +fn identical_keys_across_checkout_paths_and_machines() { + let parent = TempDir::new().unwrap(); + let copy_a = parent.path().join("machine-a").join("checkout"); + let copy_b = parent.path().join("other").join("machine-b").join("src"); + let home_a = parent.path().join("homes").join("alice"); + let home_b = parent.path().join("homes").join("bob"); + fs::create_dir_all(&home_a).unwrap(); + fs::create_dir_all(&home_b).unwrap(); + + make_fixture(©_a, false); + make_fixture(©_b, true); + assert_ne!(copy_a, copy_b); + assert!(copy_b.join("node_modules/x/index.js").is_file()); + assert!(copy_b.join("target/debug/junk").is_file()); + assert!(!copy_a.join("node_modules").exists()); + + let addresses_a = list_addresses(©_a, &home_a, false); + let addresses_b = list_addresses(©_b, &home_b, true); + assert_eq!( + addresses_a, addresses_b, + "fixture copies discovered different targets" + ); + assert!( + addresses_a.iter().any(|addr| addr == "//apps/web:build"), + "node project missing: {addresses_a:?}" + ); + assert!( + addresses_a.iter().any(|addr| addr == "//crates/a:build"), + "cargo member missing: {addresses_a:?}" + ); + assert!( + addresses_a + .iter() + .any(|addr| addr == "//services/api:build"), + "go module missing: {addresses_a:?}" + ); + + let mut keys_a = BTreeMap::new(); + let mut keys_b = BTreeMap::new(); + let mut skipped = Vec::new(); + for address in &addresses_a { + let inputs_a = match cache_inputs(©_a, &home_a, false, address) { + Ok(value) => value, + Err(error) => { + skipped.push(error); + continue; + } + }; + let inputs_b = cache_inputs(©_b, &home_b, true, address) + .unwrap_or_else(|error| panic!("copy b failed after copy a succeeded: {error}")); + + let key_a = inputs_a["key"].as_str().unwrap_or("").to_string(); + let key_b = inputs_b["key"].as_str().unwrap_or("").to_string(); + assert!( + !key_a.is_empty() && !key_b.is_empty(), + "{address} has an empty key:\n{inputs_a}\n{inputs_b}" + ); + if key_a != key_b { + panic!( + "{address} keys differ across checkouts\n a: {key_a}\n b: {key_b}\n diff: {}", + serde_json::to_string_pretty(&component_diff(&inputs_a, &inputs_b)).unwrap() + ); + } + assert!( + !format!("{inputs_a}").contains(copy_a.to_str().unwrap()), + "{address} leaked checkout path into inputs: {inputs_a}" + ); + assert!( + !format!("{inputs_b}").contains(home_b.to_str().unwrap()), + "{address} leaked HOME into inputs: {inputs_b}" + ); + keys_a.insert(address.clone(), key_a); + keys_b.insert(address.clone(), key_b); + } + + assert!( + !keys_a.is_empty(), + "no cacheable targets; skipped: {skipped:?}" + ); + assert_eq!(keys_a, keys_b); + assert!( + keys_a.contains_key("//apps/web:build"), + "web build was not cacheable: skipped {skipped:?}" + ); + assert!( + keys_a.contains_key("//crates/a:test"), + "crate test was not cacheable: skipped {skipped:?}" + ); + assert!( + keys_a.contains_key("//services/api:build"), + "go build was not cacheable: skipped {skipped:?}" + ); +}