test(cli): RED — a stale project dylib loads silently with no staleness warning
Editing project src/ without cargo build silently runs the previous dylib with plausible stale numbers (role-2 authoring-loop footgun). Pins: source mtime newer than the dylib -> stderr warning naming both timestamps, run proceeds unchanged (warn, not refuse — a stale run is still deterministic and manifest-stamped). refs #237
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
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")
|
||||
@@ -179,6 +180,89 @@ fn vocabulary_charter_violation_refuses_end_to_end() {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user