//! 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::time::{Duration, SystemTime, UNIX_EPOCH}; mod common; use common::built_project; 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 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_project(); 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_project(); 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_project(); 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_project(); let sub = dir.join("src"); let runs_dir = dir.join("runs"); std::fs::remove_dir_all(&runs_dir).ok(); let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); let out = Command::new(env!("CARGO_BIN_EXE_aura")) .args([ "sweep", &closed_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_project(); 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}" ); } fn dataonly_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/dataonly-project") } fn multicrate_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/multicrate-project") } fn nested_nodes_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/nested-nodes-project") } /// Property (#241, tier selection at the load boundary): a project whose /// `Aura.toml` has neither a `[nodes]` pointer nor a root `Cargo.toml` is the /// data-only tier — `aura run` resolves entirely against the std vocabulary /// (no crate to load, no dylib to build) and still succeeds. The widened /// `ProjectProvenance` (Tier-1 optional fields) must reflect that: the /// manifest's `project` object carries no `namespace`/`dylib_sha256` (there is /// no loaded crate to stamp) while still being present as an object (a project /// WAS discovered — `Aura.toml` exists). #[test] fn data_only_project_runs_and_stamps_commit_only_provenance() { let dir = dataonly_dir(); let out = Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "signal.json"]) .current_dir(&dir) .output() .expect("spawn aura run"); assert!( out.status.success(), "data-only run failed: {}", String::from_utf8_lossy(&out.stderr) ); let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("run report is JSON"); let p = &v["manifest"]["project"]; assert!(p.is_object(), "a project WAS discovered (Aura.toml exists): {p}"); assert!( p.get("namespace").is_none(), "no crate was loaded, so namespace must be absent, not null: {p}" ); assert!( p.get("dylib_sha256").is_none(), "no crate was loaded, so dylib_sha256 must be absent, not null: {p}" ); assert!( p["commit"].as_str().is_some_and(|s| !s.is_empty()), "a data-only project still stamps its own repo commit: {p}" ); } /// Property (#241, tier selection refuses ambiguity): more than one entry /// under `[nodes].crates` refuses at the real CLI entrypoint — before any /// subcommand-specific file I/O runs (main.rs resolves the project `Env` /// ahead of dispatch) — naming both the count and the `[nodes]` source, not a /// raw parse/index panic. #[test] fn multi_crate_pointer_refuses_end_to_end() { let dir = multicrate_dir(); let out = Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "x.json"]) .current_dir(&dir) .output() .expect("spawn aura run"); assert_eq!( out.status.code(), Some(1), "a multi-crate [nodes] config is a runtime refusal (exit 1): {:?}", out.status ); let err = String::from_utf8_lossy(&out.stderr); assert!( err.contains("multi-crate loading is not yet supported"), "stderr must name the cause: {err}" ); } /// Property (#241, [nodes] pointer indirection): the node crate need not be /// collocated with `Aura.toml` at the project root — a single `[nodes].crates` /// entry names a relative path to load instead. Driven through the REAL load /// path (cargo metadata + libloading + charter, exactly like the root-crate /// case in `project_run_manifest_carries_provenance`), not a pure unit call: /// `aura run` from the project root resolves a node type that only the /// pointed-to crate exports, and the manifest's provenance stamps that /// crate's namespace. #[test] fn nodes_pointer_crate_resolves_and_stamps_its_namespace() { let dir = nested_nodes_dir(); let crate_dir = dir.join("nodecrate"); let build = Command::new("cargo") .arg("build") .current_dir(&crate_dir) .output() .expect("spawn cargo build for the nodecrate fixture"); assert!( build.status.success(), "nodecrate fixture build failed:\n{}", String::from_utf8_lossy(&build.stderr) ); let out = Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "pointer_signal.json"]) .current_dir(&dir) .output() .expect("spawn aura run"); assert!( out.status.success(), "pointer-crate run failed: {}", String::from_utf8_lossy(&out.stderr) ); let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("run report is JSON"); assert_eq!( v["manifest"]["project"]["namespace"], "ptr", "provenance names the POINTED-TO crate's namespace, not the project root: {v}" ); } /// #241: inside a data-only project, a project-namespaced type id gets the /// "binds no node crate" hint (not the outside-project "no Aura.toml" text). #[test] fn data_only_project_hints_attach_for_namespaced_ids() { let tmp = Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-dataonly-hint"); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); std::fs::write(tmp.join("Aura.toml"), "").unwrap(); let bp = tmp.join("bp.json"); std::fs::write(&bp, r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"nosuch::Node"}}],"edges":[],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0}],"source":"F64"}],"output":[{"node":0,"field":0,"name":"bias"}]}}"#).unwrap(); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", bp.to_str().unwrap()]) .current_dir(&tmp) .output() .expect("spawn aura"); assert_eq!(out.status.code(), Some(2), "stderr: {}", String::from_utf8_lossy(&out.stderr)); let err = String::from_utf8_lossy(&out.stderr); assert!(err.contains("binds no node crate"), "{err}"); assert!(err.contains("aura nodes new"), "{err}"); std::fs::remove_dir_all(&tmp).ok(); } #[test] fn missing_artifact_refuses_with_build_hint() { // A minimal never-built project: valid cargo metadata, no dylib on disk. let tmp = Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-nobuild"); let _ = std::fs::remove_dir_all(&tmp); 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(); }