Files
Aura/crates/aura-cli/tests/project_new.rs
T
Brummel 67f98b6bc5 feat(cli): swap the scaffold's starter node for a param-bearing Scale (#183)
The `aura new` scaffold shipped one node, a param-less Identity, so a
downstream author had no public example of a build closure consuming a
bound param — the highest-value gap the project-environment fieldtest found
(F2): a newcomer could copy a param-less node but had to read engine source
to write their first *tunable* one, which the consumer contract forbids.
Swap that single starter node for a Scale node (mirroring the std gain
operator): its schema declares a `factor` ParamSpec and its build closure
reads the bound value (`|p| Box::new(Scale::new(p[0].f64()))`), and the
starter blueprint binds `factor` so `aura run` exercises the bound-param
path with zero hand-editing.

factor is bound to 1.0 — a neutral identity default the newcomer then tunes,
not the only C7-safe value (only |factor|>1 would push the terminal `bias`
output past the bounded [-1,+1] contract; the knob is genuinely read and
multiplied each cycle). The param-reading closure is the copyable template
content #183 delivers; a hypothetical `|_|` regression is caught by the
scaffold unit test's source guard, and tests/project_new.rs re-validates the
swap by building and running the scaffold end to end.

Template-side only (#181): the frozen 0102 demo-project fixture is untouched.

closes #183
2026-07-10 00:52:29 +02:00

228 lines
8.0 KiB
Rust

//! E2E: the `aura new` scaffolder (cycle 0103). Proves the full authoring
//! loop — scaffold → cargo build → aura run — with zero hand-editing, plus
//! the refusal contract. Scaffolded projects live in per-test temp dirs;
//! their path-deps resolve into this checkout (the baked engine root).
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")
}
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("aura-new-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn new_scaffold_builds_and_runs_the_authoring_loop() {
let base = tmp("loop");
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 [
"Cargo.toml",
"Aura.toml",
".gitignore",
"src/lib.rs",
"blueprints/signal.json",
"CLAUDE.md",
] {
assert!(proj.join(f).is_file(), "missing scaffolded file {f}");
}
let build = Command::new("cargo")
.arg("build")
.current_dir(&proj)
.output()
.expect("cargo build scaffolded project");
assert!(
build.status.success(),
"scaffolded build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
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");
assert_eq!(v["manifest"]["project"]["namespace"], "demo_lab");
let introspect = aura(&["graph", "introspect", "--vocabulary"], &proj);
assert!(introspect.status.success());
let text = String::from_utf8_lossy(&introspect.stdout);
assert!(text.contains("demo_lab::Scale"), "project id listed");
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);
}
#[test]
fn new_refuses_a_missing_engine_path_with_hint() {
let base = tmp("noengine");
let out = aura(&["new", "x", "--engine-path", "/nonexistent-engine"], &base);
assert_eq!(out.status.code(), Some(1), "runtime refusal");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("--engine-path"), "stderr: {err}");
let _ = std::fs::remove_dir_all(&base);
}
/// Property: an explicit `--namespace` override actually reaches the
/// runtime vocabulary (not just the `ScaffoldSpec` struct field checked at
/// the unit layer) — a scaffolded, built, run project registers its node
/// under the caller-chosen namespace, and the manifest records it too.
#[test]
fn new_namespace_override_reaches_the_built_vocabulary() {
let base = tmp("customns");
let out = aura(&["new", "demo-lab", "--namespace", "custom_ns"], &base);
assert!(
out.status.success(),
"aura new failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let proj = base.join("demo-lab");
let build = Command::new("cargo")
.arg("build")
.current_dir(&proj)
.output()
.expect("cargo build scaffolded project");
assert!(
build.status.success(),
"scaffolded build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
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");
assert_eq!(v["manifest"]["project"]["namespace"], "custom_ns");
let introspect = aura(&["graph", "introspect", "--vocabulary"], &proj);
assert!(introspect.status.success());
let text = String::from_utf8_lossy(&introspect.stdout);
assert!(text.contains("custom_ns::Scale"), "overridden namespace listed: {text}");
assert!(!text.contains("demo_lab::Scale"), "default-derived namespace must not leak");
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 [
"Cargo.toml",
"Aura.toml",
".gitignore",
"src/lib.rs",
"blueprints/signal.json",
"CLAUDE.md",
] {
assert!(proj.join(f).is_file(), "missing scaffolded file {f}");
}
// ...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);
}
#[test]
fn new_works_inside_an_unbuilt_project_tree() {
// Scaffold once (unbuilt), then scaffold AGAIN from inside that tree:
// the load-bypass guard must keep aura new from trying to load the
// enclosing project's (absent) dylib.
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/Cargo.toml").is_file());
let _ = std::fs::remove_dir_all(&base);
}