Files
Aura/crates/aura-cli/tests/project_nodes.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

214 lines
9.3 KiB
Rust

//! `aura nodes new` (#241): sibling node-crate scaffold + [nodes] attach.
use std::path::Path;
use std::process::Command;
fn temp_cwd(tag: &str) -> std::path::PathBuf {
let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-nodes-{tag}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn aura(args: &[&str], cwd: &Path) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_aura"))
.args(args)
.current_dir(cwd)
.output()
.expect("spawn aura")
}
/// Property (#258): a test sandbox must not outlive its run — `temp_cwd` must
/// hand back a run-STABLE path that lives off the `env::temp_dir()` tmpfs, so
/// its own pre-create `remove_dir_all` reclaims the previous run instead of
/// stranding one `aura-nodes-*` directory per test-binary invocation.
///
/// The helper today keys the name on `std::process::id()`, so every run mints a
/// new path the wipe can never match: >50 000 leaked dirs filled a 16 GiB tmpfs
/// to 100%. The in-repo remedy is the knob-lab scaffold's (commit 84e1075, sibling
/// `project_sweep_campaign.rs`): a fixed name under `CARGO_TARGET_TMPDIR`. This
/// pins that property at one representative site — the assertion reads the path
/// value only, so it is deterministic and does not depend on any external state.
#[test]
fn temp_cwd_sandbox_does_not_leak_under_env_temp_dir() {
let sandbox = temp_cwd("leakguard");
// Read the path value, then reclaim immediately so a red run of this very
// test does not itself add to the leak it describes.
let path = sandbox.clone();
let _ = std::fs::remove_dir_all(&sandbox);
let pid = std::process::id().to_string();
assert!(
!path.to_string_lossy().contains(&pid),
"sandbox name embeds the pid ({pid}), so no later run's pre-create wipe \
can reclaim it — one directory leaks per run: {}",
path.display(),
);
assert!(
!path.starts_with(std::env::temp_dir()),
"sandbox lives under env::temp_dir() — the tmpfs the >50k-dir leak filled: {}",
path.display(),
);
}
#[test]
fn nodes_new_scaffolds_a_sibling_crate_and_attaches_it() {
let cwd = temp_cwd("attach");
assert!(aura(&["new", "lab"], &cwd).status.success());
let proj = cwd.join("lab");
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
let out = aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("lab_nodes"), "names the namespace: {stdout}");
let crate_dir = cwd.join("lab-nodes");
assert!(crate_dir.join("Cargo.toml").is_file());
assert!(crate_dir.join("src/lib.rs").is_file());
assert!(!crate_dir.join("Aura.toml").exists(), "a node crate is not a project");
let toml = std::fs::read_to_string(proj.join("Aura.toml")).unwrap();
assert!(toml.contains("[nodes]"), "{toml}");
assert!(toml.contains("../lab-nodes"), "{toml}");
}
/// Property (#243): a freshly scaffolded node crate stamps resolvable
/// provenance too — `aura nodes new` follows the same `git init` with an
/// initial commit as `aura new` (`scaffold_node_crate`, `scaffold.rs`), so
/// `git rev-parse HEAD` resolves from the crate's very first run rather than
/// leaving HEAD unborn. This half of #243's property runs and is asserted
/// separately from `new_outside_a_work_tree_leaves_a_resolvable_head`
/// (`project_new.rs`), which covers the `aura new` project half only.
#[test]
fn nodes_new_leaves_a_resolvable_head() {
let cwd = temp_cwd("resolvable-head");
assert!(aura(&["new", "lab"], &cwd).status.success());
let proj = cwd.join("lab");
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
let out = aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let crate_dir = cwd.join("lab-nodes");
let head = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&crate_dir)
.output()
.expect("run git rev-parse HEAD");
assert!(
head.status.success(),
"fresh node-crate scaffold has no HEAD (`git rev-parse HEAD` failed): {}",
String::from_utf8_lossy(&head.stderr)
);
}
/// Property (#241): `--namespace` sets the vocabulary prefix independently
/// of the crate's own (directory) name — a crate can be named anything, but
/// the blueprints that reference its nodes address them under the namespace
/// the flag chose, never a name silently derived from the crate name. This
/// is exercised transitively by the big sweep/campaign fixture
/// (`project_sweep_campaign.rs`, `--namespace knob_lab` on a `knob-lab-nodes`
/// crate) but a failure there would not cleanly bisect to this property; this
/// test isolates it.
#[test]
fn nodes_new_namespace_flag_overrides_crate_derived_default() {
let cwd = temp_cwd("ns-override");
assert!(aura(&["new", "lab"], &cwd).status.success());
let proj = cwd.join("lab");
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
let out = aura(
&["nodes", "new", "weird-crate-name", "--namespace", "custom_ns", "--engine-path", &engine],
&proj,
);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("custom_ns"), "reports the override namespace: {stdout}");
assert!(
!stdout.contains("weird_crate_name"),
"must not fall back to the crate-derived default namespace: {stdout}"
);
let lib = std::fs::read_to_string(cwd.join("weird-crate-name/src/lib.rs")).unwrap();
assert!(
lib.contains("custom_ns::Scale") && lib.contains("namespace: \"custom_ns\""),
"the generated vocabulary is registered under the override namespace: {lib}"
);
assert!(
!lib.contains("weird_crate_name"),
"the crate-derived name must not leak into the vocabulary: {lib}"
);
}
/// Property (#241): the single-crate attach guard checks for the *presence*
/// of a `[nodes]` section in `Aura.toml`, not merely a non-empty `crates`
/// list — a hand-edited `Aura.toml` carrying an empty `[nodes]` section still
/// refuses a second attach. Without this, `aura nodes new` would scaffold a
/// crate that `append_nodes_pointer` then refuses to wire in, leaving an
/// orphaned crate on disk.
#[test]
fn nodes_new_refuses_when_nodes_section_present_but_crates_empty() {
let cwd = temp_cwd("empty-section");
assert!(aura(&["new", "lab"], &cwd).status.success());
let proj = cwd.join("lab");
let toml_path = proj.join("Aura.toml");
let mut toml = std::fs::read_to_string(&toml_path).unwrap();
toml.push_str("\n[nodes]\ncrates = []\n");
std::fs::write(&toml_path, toml).unwrap();
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
let out = aura(&["nodes", "new", "x-nodes", "--engine-path", &engine], &proj);
assert_eq!(out.status.code(), Some(1));
assert!(
String::from_utf8_lossy(&out.stderr).contains("already attached"),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!cwd.join("x-nodes").exists(),
"the guard must fire before scaffolding the crate (no orphaned crate on disk)"
);
}
#[test]
fn a_second_nodes_new_refuses_single_crate() {
let cwd = temp_cwd("second");
assert!(aura(&["new", "lab"], &cwd).status.success());
let proj = cwd.join("lab");
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
assert!(aura(&["nodes", "new", "a-nodes", "--engine-path", &engine], &proj).status.success());
let out = aura(&["nodes", "new", "b-nodes", "--engine-path", &engine], &proj);
assert_eq!(out.status.code(), Some(1));
assert!(
String::from_utf8_lossy(&out.stderr).contains("already attached"),
"{}", String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn nodes_new_outside_a_project_refuses() {
let cwd = temp_cwd("outside");
let out = aura(&["nodes", "new", "x-nodes"], &cwd);
assert_eq!(out.status.code(), Some(1));
assert!(
String::from_utf8_lossy(&out.stderr).contains("needs a project"),
"{}", String::from_utf8_lossy(&out.stderr)
);
}
/// The full role-2 loop: scaffold, build, and the project resolves the
/// crate's namespace end to end (vocabulary listing names the node).
#[test]
fn attached_crate_namespace_resolves_after_build() {
let cwd = temp_cwd("resolve");
assert!(aura(&["new", "lab"], &cwd).status.success());
let proj = cwd.join("lab");
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success());
let build = Command::new("cargo")
.arg("build")
.current_dir(cwd.join("lab-nodes"))
.output()
.expect("cargo build");
assert!(build.status.success(), "{}", String::from_utf8_lossy(&build.stderr));
let out = aura(&["graph", "introspect", "--vocabulary"], &proj);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(
String::from_utf8_lossy(&out.stdout).contains("lab_nodes::Scale"),
"{}", String::from_utf8_lossy(&out.stdout)
);
}