Files
Aura/crates/aura-cli/tests/project_new.rs
T
claude 1ccce9ad32 fix(test): anchor all test sandboxes on reclaimable pid-free names
Every hand-rolled test-sandbox helper keyed its directory on
std::process::id() under env::temp_dir(), so the pre-create wipe never
matched a prior run's path: one directory stranded per helper site per
test-binary invocation, >50k dirs / 15.5 GiB on the 16 GiB tmpfs by
2026-07-13.

The remedy extends commit 84e1075's knob-lab pattern to every site:
fixed, tag-keyed, PID-FREE names under the build-tree tmp anchor, each
site keeping its pre-create remove_dir_all — which now genuinely
reclaims the previous run. Integration/bench targets use
env!("CARGO_TARGET_TMPDIR"); src-internal unit-test helpers (cargo sets
that var only for integration/bench targets) derive the same anchor
from env!("CARGO_MANIFEST_DIR") + ../../target/tmp. Residue is bounded
to one directory per site, on disk instead of the tmpfs, per checkout.

One deliberate exception: project_new.rs's tmp() stays under
env::temp_dir() because two of its tests (new_outside_a_work_tree_*)
need a base genuinely detached from any git work tree, and target/
lives inside the checkout's work tree. Its name carries a per-checkout
hash discriminator instead, so concurrently running checkouts (which
share /tmp) cannot race on the fixed name while each run still
reclaims its predecessor.

tempfile-RAII was considered and rejected (issue #258 decision log):
it cannot cover the process-lifetime OnceLock scaffolds (statics never
drop), adds a dependency, and still strands kill-leftovers on the
tmpfs.

Verified: full workspace suite green (incl. the new RED regression
test temp_cwd_sandbox_does_not_leak_under_env_temp_dir), clippy clean,
sandboxes observed under target/tmp with stable names across runs.
Stale pre-fix PID-keyed dirs in /tmp are not swept by this change and
age out only by manual cleanup.

closes #258
2026-07-13 20:15:31 +02:00

337 lines
14 KiB
Rust

//! E2E: the `aura new` scaffolder (#241 T4). Proves the data-only authoring
//! loop — scaffold → `aura run`/`aura sweep`, zero Rust toolchain interaction
//! — plus the refusal contract. Scaffolded projects live in per-test temp
//! dirs.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn aura(args: &[&str], cwd: &Path) -> Output {
Command::new(env!("CARGO_BIN_EXE_aura"))
.args(args)
.current_dir(cwd)
.output()
.expect("run aura")
}
/// Fixed, tag-keyed, pid-free name (#258) — kept under `env::temp_dir()`
/// rather than the `CARGO_TARGET_TMPDIR` build-tree anchor used by the other
/// sandbox helpers: two of this file's tests (`new_outside_a_work_tree_*`)
/// require a base genuinely detached from any git work tree, and
/// `CARGO_TARGET_TMPDIR` resolves inside this very checkout's `target/`.
/// The checkout discriminator keeps concurrently running checkouts/worktrees
/// (which share `env::temp_dir()`) off each other's fixed names while the
/// name stays deterministic per checkout, so each run still reclaims the last.
fn tmp(tag: &str) -> PathBuf {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
env!("CARGO_MANIFEST_DIR").hash(&mut h);
let d = std::env::temp_dir().join(format!("aura-new-{tag}-{:08x}", h.finish() as u32));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Best-effort commit so a scaffolded project's `project_commit` (git
/// `rev-parse HEAD`, `project.rs`) has something to resolve: a bare fresh
/// `git init` alone (what `aura new` does outside a work tree, and what a
/// plain `git init` with no commits leaves too) has an unborn HEAD, so
/// `rev-parse HEAD` fails and `project_commit` returns `None` — verified: an
/// enclosing dir that is only `git init`-ed (no commit) makes the run
/// report's `manifest.project` render as `{}` (no `commit` key at all),
/// which is exactly the key the task text requires this test to assert on.
/// This helper is not itself required by the task text, but the assertion
/// it enables is, so it stays in scope.
fn init_and_commit(dir: &Path) {
let git = |args: &[&str]| {
let out = Command::new("git").args(args).current_dir(dir).output().expect("run git");
assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
};
git(&["init"]);
git(&["config", "user.email", "test@example.com"]);
git(&["config", "user.name", "test"]);
std::fs::write(dir.join("README.md"), "base\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "init"]);
}
/// #241 headline: a fresh data-only project runs the starter blueprint with
/// zero cargo/Rust toolchain interaction; the run report's provenance stamps
/// a commit but no namespace (no crate was ever loaded).
///
/// Documented deviation from the task text: the plan's wording asks this
/// headline to also assert that the run "writes a manifest under `x/runs/`".
/// Verified against `dispatch_run`/`run_signal_r` (main.rs, around the
/// `tx_req`/`rx_req` channel comment): plain `aura run` never touches the
/// trace store — it only prints the `RunReport` to stdout; only
/// `sweep`/`mc`/campaign verbs persist (`env.trace_store()`), a pre-#241
/// split this task's file list does not touch (giving `run` a store write
/// would widen scope beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new`
/// and this test file). There is thus no on-disk manifest for THIS verb to
/// assert against; the assertion below proves that absence directly instead
/// of only asserting it in prose, and the persisted-store property the plan
/// wanted is covered by `data_only_project_sweeps_without_any_build` below,
/// which does assert `proj.join("runs").exists()`.
#[test]
fn new_scaffolds_and_runs_the_data_only_loop() {
let base = tmp("loop");
// A committed enclosing tree gives the scaffolded project's stamped
// provenance a resolvable `git rev-parse HEAD` (a bare fresh repo has
// none yet); `aura new` skips its own git-init inside a work tree.
init_and_commit(&base);
let out = aura(&["new", "demo-lab"], &base);
assert!(
out.status.success(),
"aura new failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let proj = base.join("demo-lab");
for f in [
"Aura.toml",
".gitignore",
"blueprints/signal.json",
"CLAUDE.md",
] {
assert!(proj.join(f).is_file(), "missing scaffolded file {f}");
}
assert_eq!(
std::fs::read_dir(proj.join("blueprints")).unwrap().count(),
1,
"blueprints/signal.json must be the ONLY starter blueprint (#246)"
);
assert!(!proj.join("Cargo.toml").exists(), "data-only project must have no Cargo.toml");
assert!(!proj.join("src/lib.rs").exists(), "data-only project must have no src/lib.rs");
let a = aura(&["run", "blueprints/signal.json"], &proj);
assert!(
a.status.success(),
"run failed: {}",
String::from_utf8_lossy(&a.stderr)
);
let b = aura(&["run", "blueprints/signal.json"], &proj);
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
let v: serde_json::Value = serde_json::from_slice(&a.stdout).expect("report is JSON");
let p = &v["manifest"]["project"];
assert!(p.get("commit").is_some(), "data-only project must stamp a commit: {p}");
assert!(p.get("namespace").is_none(), "no crate was loaded, namespace must be absent: {p}");
assert!(
!proj.join("runs").exists(),
"plain `aura run` must not persist a trace store (only sweep/mc/campaign do) — \
the task text's \"writes a manifest under x/runs/\" does not hold for this verb"
);
let _ = std::fs::remove_dir_all(&base);
}
/// #241 headline: a fresh data-only project sweeps a dissolved verb (#218's
/// gate) with zero build step — using the scaffolded (closed) starter
/// blueprint and the exact axis the scaffolded CLAUDE.md advertises.
///
/// #246: the closed starter IS the sweep target — a bound param is a default
/// an axis overrides.
#[test]
fn data_only_project_sweeps_without_any_build() {
let base = tmp("sweep");
let new = aura(&["new", "scratch"], &base);
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
let proj = base.join("scratch");
let out = aura(
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4"],
&proj,
);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(proj.join("runs").exists(), "store must land inside the project");
let _ = std::fs::remove_dir_all(&base);
}
/// Property (#246): the scaffold's sweep quickstart genuinely re-opens the
/// bound `fast.length` param — the persisted family has exactly one member
/// per axis value, not a single collapsed member. A silent regression where
/// the bound-param override is dropped (member always built from the
/// blueprint's own default) would still exit 0 and still create a `runs`
/// store, so `data_only_project_sweeps_without_any_build` above cannot catch
/// it; only the member count can.
#[test]
fn data_only_project_sweep_over_the_starter_opens_one_member_per_axis_value() {
let base = tmp("sweep-members");
let new = aura(&["new", "scratch"], &base);
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
let proj = base.join("scratch");
let out = aura(
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4,8"],
&proj,
);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let fams = aura(&["runs", "families"], &proj);
assert!(fams.status.success(), "runs families exit: {:?}", fams.status);
let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
assert_eq!(fams_out.lines().count(), 1, "exactly one family persisted: {fams_out}");
assert!(
fams_out.contains("\"members\":3"),
"the three-value bound-param axis grid must yield three members, one per \
value (2,4,8) — not a collapsed default-only sweep: {fams_out}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn new_refuses_an_existing_destination() {
let base = tmp("exists");
std::fs::create_dir_all(base.join("taken")).unwrap();
let out = aura(&["new", "taken"], &base);
assert_eq!(out.status.code(), Some(1), "runtime refusal");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("already exists"), "stderr: {err}");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn new_rejects_invalid_names_as_usage_faults() {
let base = tmp("badname");
for bad in ["bad/name", "9lives"] {
let out = aura(&["new", bad], &base);
assert_eq!(out.status.code(), Some(2), "usage fault for {bad}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("invalid project name"), "stderr: {err}");
}
let _ = std::fs::remove_dir_all(&base);
}
/// Property: `aura new` mirrors `cargo new` — when the target directory is
/// already inside a git work tree, the scaffolder does NOT initialize a
/// nested repo. A nested `.git` under a tracked tree is a gitlink/submodule
/// hazard (it bit the 0107 fieldtest corpus); the enclosing repo owns the
/// scaffold's history.
#[test]
fn new_inside_a_work_tree_skips_the_nested_git() {
let base = tmp("insidegit");
let init = Command::new("git")
.arg("init")
.current_dir(&base)
.output()
.expect("git init the enclosing work tree");
assert!(
init.status.success(),
"git init base failed: {}",
String::from_utf8_lossy(&init.stderr)
);
let out = aura(&["new", "demo-lab"], &base);
assert!(
out.status.success(),
"aura new failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let proj = base.join("demo-lab");
// The full scaffold still lands unchanged...
for f in [
"Aura.toml",
".gitignore",
"blueprints/signal.json",
"CLAUDE.md",
] {
assert!(proj.join(f).is_file(), "missing scaffolded file {f}");
}
assert_eq!(
std::fs::read_dir(proj.join("blueprints")).unwrap().count(),
1,
"blueprints/signal.json must be the ONLY starter blueprint (#246)"
);
// ...but without a nested repo of its own.
assert!(
!proj.join(".git").exists(),
"scaffold nested a .git inside an existing work tree (gitlink hazard)"
);
let _ = std::fs::remove_dir_all(&base);
}
/// Guard against over-removal of the git-init: OUTSIDE any work tree the
/// scaffold still initializes its own repo. `std::env::temp_dir()` is not
/// inside a git work tree (the e2e helpers here already rely on that), so the
/// scaffold's parent is genuinely detached from this checkout's tree.
#[test]
fn new_outside_a_work_tree_initializes_git() {
let base = tmp("outsidegit");
let out = aura(&["new", "demo-lab"], &base);
assert!(
out.status.success(),
"aura new failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let proj = base.join("demo-lab");
assert!(
proj.join(".git").is_dir(),
"scaffold outside a work tree must initialize its own repo"
);
let _ = std::fs::remove_dir_all(&base);
}
/// Property (#243): a freshly scaffolded project stamps resolvable
/// provenance from its very first run. Outside any enclosing work tree
/// `aura new` owns the scaffold's history, so it must leave the project with
/// a HEAD — `git rev-parse HEAD` resolves — and the first advertised
/// quickstart `aura run` therefore stamps a `manifest.project.commit`, rather
/// than the empty `"project":{}` that a bare `git init` with no commit yields
/// (a first manifest that is silently un-reproducible-by-commit). This is the
/// property the neighbouring loop headline can only reach today via a manual
/// `init_and_commit` on the enclosing tree.
#[test]
fn new_outside_a_work_tree_leaves_a_resolvable_head() {
let base = tmp("headprov");
let out = aura(&["new", "demo-lab"], &base);
assert!(
out.status.success(),
"aura new failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let proj = base.join("demo-lab");
// The scaffold has its own repo (guarded elsewhere) AND that repo has a
// HEAD: `git rev-parse HEAD` resolves. A bare `git init` with no commit
// leaves HEAD unborn and this fails.
let head = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&proj)
.output()
.expect("run git rev-parse HEAD");
assert!(
head.status.success(),
"fresh scaffold has no HEAD (`git rev-parse HEAD` failed): {}",
String::from_utf8_lossy(&head.stderr)
);
// ...so the first quickstart run stamps a commit into the manifest, not
// an empty provenance block.
let run = aura(&["run", "blueprints/signal.json"], &proj);
assert!(
run.status.success(),
"run failed: {}",
String::from_utf8_lossy(&run.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&run.stdout).expect("report is JSON");
let p = &v["manifest"]["project"];
assert!(
p.get("commit").is_some(),
"first run of a fresh scaffold must stamp a commit, not empty provenance: {p}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn new_works_inside_an_unbuilt_project_tree() {
// Scaffold once, then scaffold AGAIN from inside that tree: the
// load-bypass guard must keep aura new from trying to load the enclosing
// project's (data-only, but still discoverable) Aura.toml.
let base = tmp("nested");
let out = aura(&["new", "outer"], &base);
assert!(out.status.success());
let inner_cwd = base.join("outer");
let out = aura(&["new", "inner"], &inner_cwd);
assert!(
out.status.success(),
"aura new inside an unbuilt project tree must succeed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(inner_cwd.join("inner/Aura.toml").is_file());
let _ = std::fs::remove_dir_all(&base);
}