//! E2E: the project-as-crate load boundary (cycle 0102). Builds the fixture //! project once (cargo, same toolchain, path-dep on this workspace's //! aura-core), then drives the aura binary from inside the fixture dir. //! `aura run` persists nothing without --trace, so the tracked fixture stays //! clean (target/, Cargo.lock, runs/ are fixture-gitignored). use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::sync::OnceLock; use std::time::{Duration, SystemTime, UNIX_EPOCH}; fn fixture_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo-project") } fn badcharter_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/badcharter-project") } fn built_fixture() -> &'static PathBuf { static BUILT: OnceLock = OnceLock::new(); BUILT.get_or_init(|| { let dir = fixture_dir(); let out = Command::new("cargo") .arg("build") .current_dir(&dir) .output() .expect("spawn cargo build for the fixture project"); assert!( out.status.success(), "fixture build failed:\n{}", String::from_utf8_lossy(&out.stderr) ); dir }) } fn aura(args: &[&str], cwd: &Path) -> Output { Command::new(env!("CARGO_BIN_EXE_aura")) .args(args) .current_dir(cwd) .output() .expect("run aura") } #[test] fn project_run_resolves_demo_node_and_is_bit_identical() { let dir = built_fixture(); let a = aura(&["run", "demo_signal.json"], dir); assert!( a.status.success(), "run failed: {}", String::from_utf8_lossy(&a.stderr) ); let b = aura(&["run", "demo_signal.json"], dir); assert!(b.status.success()); assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)"); } #[test] fn project_run_manifest_carries_provenance() { let dir = built_fixture(); let out = aura(&["run", "demo_signal.json"], dir); assert!(out.status.success()); let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("run report is JSON"); let p = &v["manifest"]["project"]; assert_eq!(p["namespace"], "demo"); let sha = p["dylib_sha256"].as_str().expect("hash present"); assert_eq!(sha.len(), 64, "sha256 hex"); } #[test] fn introspect_vocabulary_lists_project_types() { let dir = built_fixture(); let out = aura(&["graph", "introspect", "--vocabulary"], dir); assert!(out.status.success()); let text = String::from_utf8_lossy(&out.stdout); assert!(text.contains("demo::Identity"), "project id listed"); assert!(text.contains("SMA"), "std ids still listed"); } /// (#184/#185) Outside any project, `demo::Identity` resolves nowhere: the base /// failure is house-style prose (never the raw `UnknownNodeType` Debug leak), /// and — because the unresolved id LOOKS project-namespaced (`::`) while no /// `Aura.toml` was found up from cwd — the message carries a hint pointing at /// the missing project, distinguishing "no project" from "genuinely unknown /// std id" (the bare-id case in `cli_run.rs` gets no such hint). #[test] fn outside_a_project_the_demo_blueprint_is_unknown() { // cwd = the aura-cli crate dir: no Aura.toml anywhere up the tree. let cwd = Path::new(env!("CARGO_MANIFEST_DIR")); let bp = fixture_dir().join("demo_signal.json"); let out = aura(&["run", bp.to_str().unwrap()], cwd); assert!(!out.status.success()); let err = String::from_utf8_lossy(&out.stderr); assert!( err.contains(r#"unknown node type "demo::Identity""#), "house-style prose names the type: {err}" ); assert!(!err.contains("UnknownNodeType"), "does not leak the Debug variant name: {err}"); assert!( err.contains("Aura.toml"), "hints that no project was found for the namespaced id: {err}" ); } /// `Env::runs_root()` resolves `Aura.toml`'s `[paths] runs = "runs"` against /// the DISCOVERED project root (the `Aura.toml` directory found by walking up /// from cwd), not against the invocation cwd itself: a family recorded while /// invoking `aura` from a project subdirectory must still land under /// `/runs/`, and no stray `runs/` must appear under the /// subdirectory. This is the property that makes `[paths]` project-relative /// rather than shell-relative (C17). #[test] fn project_registry_anchors_at_discovered_root_not_invocation_cwd() { let dir = built_fixture(); let sub = dir.join("src"); let runs_dir = dir.join("runs"); std::fs::remove_dir_all(&runs_dir).ok(); let open_bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = Command::new(env!("CARGO_BIN_EXE_aura")) .args([ "sweep", &open_bp, "--axis", "sma_signal.fast.length=2,4", "--axis", "sma_signal.slow.length=8,16", "--name", "proj-anchor", ]) .current_dir(&sub) .output() .expect("spawn aura sweep"); assert!( out.status.success(), "sweep failed: {}", String::from_utf8_lossy(&out.stderr) ); assert!( runs_dir.join("families.jsonl").is_file(), "family record must land at the project root's runs/, not the invocation cwd's" ); assert!( !sub.join("runs").exists(), "no stray runs/ must be created under the invocation subdirectory" ); std::fs::remove_dir_all(&runs_dir).ok(); } /// A project whose listed type id is not `::`-prefixed is refused /// (exit 1, cause named) through the REAL load path — `libloading` + /// descriptor read + `check_charter` wired together in `project::load` — not /// merely the pure `check_charter` function (already unit-tested in /// `aura-cli::project`). Pins the "refuse-don't-guess" vocabulary charter as /// an end-to-end guarantee: a regression that skipped the charter call in the /// `load()` glue would pass every existing unit test but still be caught here. #[test] fn vocabulary_charter_violation_refuses_end_to_end() { let dir = badcharter_dir(); let build = Command::new("cargo") .arg("build") .current_dir(&dir) .output() .expect("spawn cargo build for the badcharter fixture"); assert!( build.status.success(), "badcharter fixture build failed:\n{}", String::from_utf8_lossy(&build.stderr) ); let run = aura(&["run", "x.json"], &dir); assert_eq!( run.status.code(), Some(1), "a charter violation is a runtime refusal (exit 1), not a usage error" ); let err = String::from_utf8_lossy(&run.stderr); assert!(err.contains("lacks the project prefix"), "stderr must name the charter cause: {err}"); } /// Force a file's modification time to a fixed instant (explicit mtime, no /// sleeping — mtime granularity would make a "touch then compare" race). fn set_mtime(path: &Path, secs_since_epoch: u64) { let t = UNIX_EPOCH + Duration::from_secs(secs_since_epoch); let f = std::fs::OpenOptions::new() .write(true) .open(path) .unwrap_or_else(|e| panic!("open {} to set mtime: {e}", path.display())); f.set_modified(t) .unwrap_or_else(|e| panic!("set mtime on {}: {e}", path.display())); } /// The role-2 authoring loop is edit -> `cargo build` -> run (C25). A forgotten /// build is a silent-wrong-results footgun: the loader load-and-holds the /// *previous* dylib and reports plausible, stale numbers. The property: when a /// source file under the project is newer (mtime) than the built dylib, the run /// still proceeds — a stale run is deterministic and manifest-stamped, so a /// refusal is not warranted — but it surfaces the staleness on stderr, naming /// both the dylib's and the source's modification times so the omission is /// visible instead of silent. #[test] fn stale_dylib_warns_naming_both_timestamps_but_still_runs() { let dir = built_fixture(); let src = dir.join("src/lib.rs"); let dylib = dir.join("target/debug").join(format!( "{}demo_project{}", std::env::consts::DLL_PREFIX, std::env::consts::DLL_SUFFIX, )); assert!( dylib.is_file(), "built fixture must have produced a dylib at {}", dylib.display() ); // Pin two far-apart, distinctive mtimes so any human-readable rendering of // either timestamp carries a stable, non-colliding year token. Source // (2033) is newer than the dylib (2001): stale. const DYLIB_MTIME: u64 = 993_859_200; // 2001-06-30 UTC const SOURCE_MTIME: u64 = 2_003_702_400; // 2033-06-30 UTC set_mtime(&dylib, DYLIB_MTIME); set_mtime(&src, SOURCE_MTIME); let out = aura(&["run", "demo_signal.json"], dir); // Restore sane mtimes before asserting, so a failed assertion does not // leave the shared fixture wedged "stale" (or with a future mtime that // makes every later `cargo build` re-fingerprint it). let now = SystemTime::now(); let _ = std::fs::OpenOptions::new() .write(true) .open(&dylib) .and_then(|f| f.set_modified(now)); let _ = std::fs::OpenOptions::new() .write(true) .open(&src) .and_then(|f| f.set_modified(now)); // Warn, not refuse: the run still succeeds and still emits its JSON report. assert!( out.status.success(), "a stale dylib warns but the run proceeds (exit 0): {}", String::from_utf8_lossy(&out.stderr) ); let report: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stale run still emits its JSON report"); assert_eq!( report["manifest"]["project"]["namespace"], "demo", "the stale run behaves exactly like a fresh one" ); // Names both timestamps: the dylib's (2001) and the newer source's (2033). let err = String::from_utf8_lossy(&out.stderr); assert!( err.contains("2001"), "stderr warning must name the dylib's modification time (2001): {err}" ); assert!( err.contains("2033"), "stderr warning must name the newer source's modification time (2033): {err}" ); } #[test] fn missing_artifact_refuses_with_build_hint() { // A minimal never-built project: valid cargo metadata, no dylib on disk. let tmp = std::env::temp_dir().join(format!("aura-nobuild-{}", std::process::id())); std::fs::create_dir_all(tmp.join("src")).unwrap(); std::fs::write(tmp.join("Aura.toml"), "").unwrap(); std::fs::write(tmp.join("src/lib.rs"), "").unwrap(); std::fs::write( tmp.join("Cargo.toml"), "[package]\nname = \"nobuild\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[workspace]\n", ) .unwrap(); let out = aura(&["run", "x.json"], &tmp); assert_eq!(out.status.code(), Some(1), "runtime refusal"); let err = String::from_utf8_lossy(&out.stderr); assert!(err.contains("cargo build"), "build hint present: {err}"); std::fs::remove_dir_all(&tmp).ok(); }