mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
219 lines
8.2 KiB
Rust
219 lines
8.2 KiB
Rust
//! IMPECCABLE_CACHE_ROOT (#422) — mirrors the scenarios main's
|
|
//! tests/hook.test.mjs added in 77a2eae8 / 5c82d58b / 30b3628f / cbd78701.
|
|
//!
|
|
//! These tests mutate the process environment, so they live in their own
|
|
//! test binary (its own process) and serialize on ENV_LOCK.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::Mutex;
|
|
|
|
use impeccable_detect::MissingHtmlEngine;
|
|
use impeccable_hook::hook_lib::{get_cache_path, get_pending_path, Runtime};
|
|
use impeccable_hook::hook;
|
|
use serde_json::json;
|
|
|
|
static HTML: MissingHtmlEngine = MissingHtmlEngine;
|
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
struct EnvGuard {
|
|
saved: Vec<(&'static str, Option<String>)>,
|
|
}
|
|
impl EnvGuard {
|
|
fn set(pairs: &[(&'static str, Option<&str>)]) -> EnvGuard {
|
|
let mut saved = Vec::new();
|
|
for (k, v) in pairs {
|
|
saved.push((*k, std::env::var(k).ok()));
|
|
match v {
|
|
Some(v) => std::env::set_var(k, v),
|
|
None => std::env::remove_var(k),
|
|
}
|
|
}
|
|
EnvGuard { saved }
|
|
}
|
|
}
|
|
impl Drop for EnvGuard {
|
|
fn drop(&mut self) {
|
|
for (k, v) in &self.saved {
|
|
match v {
|
|
Some(v) => std::env::set_var(k, v),
|
|
None => std::env::remove_var(k),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Tmp(PathBuf);
|
|
impl Tmp {
|
|
fn new() -> Tmp {
|
|
let base = std::env::temp_dir().join(format!(
|
|
"impeccable-cache-root-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
|
|
));
|
|
std::fs::create_dir_all(&base).unwrap();
|
|
Tmp(std::fs::canonicalize(&base).unwrap())
|
|
}
|
|
fn path(&self) -> String {
|
|
self.0.to_string_lossy().into_owned()
|
|
}
|
|
fn write(&self, rel: &str, body: &str) -> String {
|
|
let abs = self.0.join(rel);
|
|
std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
|
|
std::fs::write(&abs, body).unwrap();
|
|
abs.to_string_lossy().into_owned()
|
|
}
|
|
}
|
|
impl Drop for Tmp {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn rt(cwd: &str) -> Runtime<'static> {
|
|
Runtime::new(cwd.to_string(), HashMap::new(), "/impeccable".to_string(), "/opt/bin/impeccable", &HTML)
|
|
}
|
|
|
|
fn edit_event(cwd: &str, file: &str, session: &str) -> String {
|
|
json!({
|
|
"session_id": session, "cwd": cwd, "hook_event_name": "PostToolUse",
|
|
"tool_name": "Edit", "tool_input": { "file_path": file },
|
|
})
|
|
.to_string()
|
|
}
|
|
|
|
const GRADIENT_CSS: &str = ".title { background: linear-gradient(90deg, #f472b6, #a78bfa); -webkit-background-clip: text; color: transparent; }\n";
|
|
const CLEAN_CSS: &str = ".card { color: #333; }\n";
|
|
|
|
#[test]
|
|
fn state_relocates_and_slug_normalizes() {
|
|
let _l = ENV_LOCK.lock().unwrap();
|
|
let root = Tmp::new();
|
|
let _g = EnvGuard::set(&[("IMPECCABLE_CACHE_ROOT", Some(&root.path()))]);
|
|
|
|
let cache = get_cache_path("/x/my.app");
|
|
assert!(cache.starts_with(&root.path()), "{}", cache);
|
|
assert!(cache.ends_with("hook.cache.json"));
|
|
// Pending lands in the same per-project dir.
|
|
let pending = get_pending_path("/x/my.app");
|
|
assert_eq!(
|
|
std::path::Path::new(&cache).parent(),
|
|
std::path::Path::new(&pending).parent()
|
|
);
|
|
// Trailing separators and relative segments slug to the same dir.
|
|
assert_eq!(get_cache_path("/x/my.app"), get_cache_path("/x/my.app/"));
|
|
assert_eq!(get_cache_path("/x/my.app"), get_cache_path("/x/other/../my.app"));
|
|
// The readable part stays human-scannable and the 8-hex digest keeps
|
|
// colliding readable slugs apart (`/x/my.app` vs `/x/my-app`).
|
|
let dir = std::path::Path::new(&cache).parent().unwrap().file_name().unwrap().to_string_lossy().into_owned();
|
|
assert!(dir.starts_with("-x-my-app-"), "{}", dir);
|
|
let digest = dir.rsplit('-').next().unwrap();
|
|
assert_eq!(digest.len(), 8);
|
|
assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
|
|
assert_ne!(get_cache_path("/x/my.app"), get_cache_path("/x/my-app"));
|
|
}
|
|
|
|
/// The project-local cache path for `/x/app`, joined with the host's path
|
|
/// semantics (backslashes on Windows), which is what the stock behavior
|
|
/// produces.
|
|
fn stock_cache_path() -> String {
|
|
impeccable_common::jsp::join(&["/x/app", ".impeccable", "hook.cache.json"])
|
|
}
|
|
|
|
#[test]
|
|
fn root_value_normalization_and_opt_out() {
|
|
let _l = ENV_LOCK.lock().unwrap();
|
|
let root = Tmp::new();
|
|
// Stray whitespace in env files trims away.
|
|
let padded = format!(" {} ", root.path());
|
|
let trimmed = {
|
|
let _g = EnvGuard::set(&[("IMPECCABLE_CACHE_ROOT", Some(&root.path()))]);
|
|
get_cache_path("/x/app")
|
|
};
|
|
let with_ws = {
|
|
let _g = EnvGuard::set(&[("IMPECCABLE_CACHE_ROOT", Some(&padded))]);
|
|
get_cache_path("/x/app")
|
|
};
|
|
assert_eq!(trimmed, with_ws);
|
|
// Unset or blank keeps stock project-local behavior.
|
|
{
|
|
let _g = EnvGuard::set(&[("IMPECCABLE_CACHE_ROOT", None)]);
|
|
assert_eq!(get_cache_path("/x/app"), stock_cache_path());
|
|
}
|
|
{
|
|
let _g = EnvGuard::set(&[("IMPECCABLE_CACHE_ROOT", Some(" "))]);
|
|
assert_eq!(get_cache_path("/x/app"), stock_cache_path());
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn tilde_expands_against_homedir_or_rejects() {
|
|
let _l = ENV_LOCK.lock().unwrap();
|
|
let home = Tmp::new();
|
|
let explicit = {
|
|
let joined = format!("{}/caches", home.path());
|
|
let _g = EnvGuard::set(&[("HOME", Some(&home.path())), ("IMPECCABLE_CACHE_ROOT", Some(&joined))]);
|
|
get_cache_path("/x/app")
|
|
};
|
|
let tilde = {
|
|
let _g = EnvGuard::set(&[("HOME", Some(&home.path())), ("IMPECCABLE_CACHE_ROOT", Some("~/caches"))]);
|
|
get_cache_path("/x/app")
|
|
};
|
|
assert_eq!(explicit, tilde);
|
|
// No determinable home dir: expansion is rejected and state falls back
|
|
// to the project-local default (never the process cwd).
|
|
let no_home = {
|
|
let _g = EnvGuard::set(&[("HOME", None), ("USERPROFILE", None), ("IMPECCABLE_CACHE_ROOT", Some("~/caches"))]);
|
|
get_cache_path("/x/app")
|
|
};
|
|
assert_eq!(no_home, "/x/app/.impeccable/hook.cache.json");
|
|
}
|
|
|
|
#[test]
|
|
fn run_hook_persists_and_dedupes_through_the_redirect() {
|
|
let _l = ENV_LOCK.lock().unwrap();
|
|
let root = Tmp::new();
|
|
let project = Tmp::new();
|
|
let _g = EnvGuard::set(&[("IMPECCABLE_CACHE_ROOT", Some(&root.path()))]);
|
|
let cwd = project.path();
|
|
let r = rt(&cwd);
|
|
let css = project.write("src/a.css", GRADIENT_CSS);
|
|
|
|
let one = hook::run_hook(&r, &edit_event(&cwd, &css, "s1"));
|
|
assert!(one.stdout.contains("gradient-text"), "{}", one.stdout);
|
|
// State lands under the redirect root; the project stays footprint-free.
|
|
assert!(std::path::Path::new(&get_cache_path(&cwd)).exists());
|
|
assert!(!project.0.join(".impeccable").exists());
|
|
|
|
// The remembered finding dedupes the second identical edit into pending.
|
|
let two = hook::run_hook(&r, &edit_event(&cwd, &css, "s1"));
|
|
assert!(two.stdout.contains("flagged earlier this session"), "{}", two.stdout);
|
|
|
|
// A clean edit still persists its editCount bump: the redirected cache
|
|
// file is the opt-in marker even though `.impeccable/` never appears.
|
|
let clean = project.write("src/b.css", CLEAN_CSS);
|
|
let before = std::fs::read_to_string(get_cache_path(&cwd)).unwrap();
|
|
let three = hook::run_hook(&r, &edit_event(&cwd, &clean, "s1"));
|
|
assert_eq!(three.audit.get("kind").and_then(|v| v.as_str()), Some("clean"));
|
|
let after = std::fs::read_to_string(get_cache_path(&cwd)).unwrap();
|
|
assert_ne!(before, after, "clean-edit editCount bump persisted through the redirect");
|
|
}
|
|
|
|
#[test]
|
|
fn no_footprint_noop_gate_holds_under_redirect() {
|
|
let _l = ENV_LOCK.lock().unwrap();
|
|
let root = Tmp::new();
|
|
let project = Tmp::new();
|
|
let _g = EnvGuard::set(&[("IMPECCABLE_CACHE_ROOT", Some(&root.path()))]);
|
|
let cwd = project.path();
|
|
let r = rt(&cwd);
|
|
// A clean UI edit in a project with no Impeccable footprint must be a
|
|
// no-op on disk (issues #344, #305), redirect or not.
|
|
let clean = project.write("src/b.css", CLEAN_CSS);
|
|
let res = hook::run_hook(&r, &edit_event(&cwd, &clean, "s1"));
|
|
assert_eq!(res.audit.get("kind").and_then(|v| v.as_str()), Some("clean"));
|
|
assert!(!std::path::Path::new(&get_cache_path(&cwd)).exists());
|
|
assert!(!project.0.join(".impeccable").exists());
|
|
}
|