7ae92d3e60
closes #60 The fixture-corpus filter (NON_PARSEABLE_FIXTURES + list_ail_fixtures) was copy-pasted across three test crates and drifted out of sync as new reject fixtures landed; the examples_dir / workspace_root path walk was reimplemented ~30 more times across the suite, under two spellings (workspace_root / ws_root). Introduce crates/ailang-test-support — a zero-dependency, dev-only leaf crate — as the single home for these helpers: - NON_PARSEABLE_FIXTURES, list_ail_fixtures - workspace_root, examples_dir - canonical_workspace_root (symlink-resolved variant, for the tsan/ race tests that feed the root into native build steps) All integration-test copies across ailang-core, ailang-surface, ailang-prose, ailang-check, and ail now import from the shared crate; the local definitions and their now-orphaned path imports are removed. Path helpers resolve via the support crate's own CARGO_MANIFEST_DIR, so they return the correct absolute paths from any caller. ail_bin helpers are intentionally left in place: they depend on env!("CARGO_BIN_EXE_ail"), which Cargo only defines when compiling the ail crate's own integration tests, so they cannot move to a shared crate. Behaviour-identical: same paths, same fixture lists; full workspace test suite green. Net -177 LOC.
136 lines
4.8 KiB
Rust
136 lines
4.8 KiB
Rust
//! CLI-pipeline idempotency gate: every `examples/*.ail` survives
|
|
//! `ail parse | ail render | ail parse` with byte identity on the
|
|
//! canonical JSON output.
|
|
//!
|
|
//! This is the user-facing-surface defence line. Crate-internal
|
|
//! roundtrip tests in `ailang-surface` cover the same property
|
|
//! at the library level; this test additionally protects the
|
|
//! `ail parse` and `ail render` CLI wrappers from drift (output
|
|
//! formatting, exit codes, stdout framing).
|
|
//!
|
|
//! Pure reader: the test writes only to a `tempfile::TempDir`
|
|
//! that lives outside the repo and is cleaned up by Drop. No
|
|
//! committed content is mutated.
|
|
//!
|
|
//! Retired iter form-a.1 T9:
|
|
//! `cli_render_then_parse_preserves_canonical_bytes_on_every_fixture`
|
|
//! (walked `.ail.json` corpus; replaced by the corpus-flipped
|
|
//! `cli_parse_then_render_then_parse_is_idempotent` below, added in
|
|
//! T1). The `list_json_fixtures` helper and `roundtrip_one` helper
|
|
//! were the only consumers of `.ail.json` walks and are retired
|
|
//! alongside.
|
|
|
|
use ailang_test_support::{examples_dir, list_ail_fixtures};
|
|
use std::process::Command;
|
|
|
|
fn ail_bin() -> &'static str {
|
|
env!("CARGO_BIN_EXE_ail")
|
|
}
|
|
|
|
/// CLI-pipeline idempotency (post-form-a-default-authoring §C3): for
|
|
/// every `.ail` fixture, the user-facing pipeline
|
|
/// `ail parse <ail> | ail render | ail parse` is byte-identical
|
|
/// to direct `ail parse <ail>`. Pins drift internal tests cannot see.
|
|
#[test]
|
|
fn cli_parse_then_render_then_parse_is_idempotent() {
|
|
let fixtures = list_ail_fixtures();
|
|
assert!(
|
|
!fixtures.is_empty(),
|
|
"no .ail fixtures found under {}",
|
|
examples_dir().display()
|
|
);
|
|
|
|
let tmpdir = tempfile::TempDir::new().expect("create tempdir");
|
|
let mut checked = 0usize;
|
|
let mut failures = Vec::<String>::new();
|
|
|
|
for ail_path in &fixtures {
|
|
// Direct parse → JSON tempfile.
|
|
let json1 = tmpdir.path().join(format!("v1_{checked}.ail.json"));
|
|
let parse1 = Command::new(ail_bin())
|
|
.args([
|
|
"parse",
|
|
ail_path.to_str().unwrap(),
|
|
"-o",
|
|
json1.to_str().unwrap(),
|
|
])
|
|
.output()
|
|
.expect("spawn ail parse #1");
|
|
if !parse1.status.success() {
|
|
failures.push(format!(
|
|
"{}: first ail parse exit={:?}\nstderr: {}",
|
|
ail_path.display(),
|
|
parse1.status.code(),
|
|
String::from_utf8_lossy(&parse1.stderr),
|
|
));
|
|
checked += 1;
|
|
continue;
|
|
}
|
|
|
|
// Render that JSON → .ail tempfile.
|
|
let ail_round = tmpdir.path().join(format!("round_{checked}.ail"));
|
|
let rendered = Command::new(ail_bin())
|
|
.args(["render", json1.to_str().unwrap()])
|
|
.output()
|
|
.expect("spawn ail render");
|
|
if !rendered.status.success() {
|
|
failures.push(format!(
|
|
"{}: ail render exit={:?}\nstderr: {}",
|
|
ail_path.display(),
|
|
rendered.status.code(),
|
|
String::from_utf8_lossy(&rendered.stderr),
|
|
));
|
|
checked += 1;
|
|
continue;
|
|
}
|
|
std::fs::write(&ail_round, &rendered.stdout)
|
|
.expect("write ail_round tempfile");
|
|
|
|
// Re-parse → second JSON tempfile.
|
|
let json2 = tmpdir.path().join(format!("v2_{checked}.ail.json"));
|
|
let parse2 = Command::new(ail_bin())
|
|
.args([
|
|
"parse",
|
|
ail_round.to_str().unwrap(),
|
|
"-o",
|
|
json2.to_str().unwrap(),
|
|
])
|
|
.output()
|
|
.expect("spawn ail parse #2");
|
|
if !parse2.status.success() {
|
|
failures.push(format!(
|
|
"{}: second ail parse exit={:?}\nstderr: {}",
|
|
ail_path.display(),
|
|
parse2.status.code(),
|
|
String::from_utf8_lossy(&parse2.stderr),
|
|
));
|
|
checked += 1;
|
|
continue;
|
|
}
|
|
|
|
// Assert the two canonical JSONs are byte-equal.
|
|
let b1 = std::fs::read(&json1).expect("read json1");
|
|
let b2 = std::fs::read(&json2).expect("read json2");
|
|
if b1 != b2 {
|
|
let s1 = String::from_utf8_lossy(&b1).into_owned();
|
|
let s2 = String::from_utf8_lossy(&b2).into_owned();
|
|
failures.push(format!(
|
|
"{}: CLI parse|render|parse non-idempotent.\n v1: {s1}\n v2: {s2}",
|
|
ail_path.display(),
|
|
));
|
|
}
|
|
checked += 1;
|
|
}
|
|
|
|
if !failures.is_empty() {
|
|
panic!(
|
|
"CLI parse|render|parse non-idempotent on {} of {} fixtures:\n{}",
|
|
failures.len(),
|
|
checked,
|
|
failures.join("\n\n")
|
|
);
|
|
}
|
|
assert!(checked > 0, "no .ail fixtures found");
|
|
eprintln!("CLI parse|render|parse idempotent for {checked} fixtures");
|
|
}
|