use std::fs;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
struct Fixture {
root: std::path::PathBuf,
}
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"noxid-scenario-runner-{}-{}",
std::process::id(),
NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&root).expect("create scenario-runner fixture");
Self { root }
}
fn write(&self, relative: &str, contents: &str) {
fs::write(self.root.join(relative), contents).expect("write scenario-runner fixture");
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
#[test]
fn sixty_failing_boundary_properties_do_not_deadlock_the_scenario_report() {
let fixture = Fixture::new();
let mut source = String::new();
for index in 0..60 {
source.push_str(&format!(
"endpoint Boundary{index} {{\n body {{ name: String }}\n result: Boolean\n timeout: 1s\n property Fails{index} {{ runs: 1 expect: validates or refuses }}\n}}\n"
));
}
fixture.write("Boundaries.nox", &source);
fixture.write(
"hook-entry.mjs",
"import { register } from \"node:module\";\nregister(\"./hook.mjs\", import.meta.url);\n",
);
fixture.write(
"validator.mjs",
r#"export class ExternalValidationError extends Error {}
export const typeValidators = new Proxy(Object.create(null), {
get() {
return () => { throw new Error("BOUNDARY_FAILURE_" + "x".repeat(800)); };
},
});
"#,
);
fixture.write(
"hook.mjs",
"import { readFileSync } from \"node:fs\";\nconst source = readFileSync(new URL(\"./validator.mjs\", import.meta.url), \"utf8\");\nexport async function load(url, context, next) {\n if (url.endsWith(\".validators.js\")) return { format: \"module\", shortCircuit: true, source };\n return next(url, context);\n}\n",
);
let stdout_path = fixture.root.join("stdout.txt");
let stderr_path = fixture.root.join("stderr.txt");
let stdout = fs::File::create(&stdout_path).expect("create captured stdout");
let stderr = fs::File::create(&stderr_path).expect("create captured stderr");
let mut child = Command::new(env!("CARGO_BIN_EXE_noxid"))
.args(["test", "Boundaries.nox", "--json"])
.current_dir(&fixture.root)
.env(
"NODE_OPTIONS",
format!("--import {}", fixture.root.join("hook-entry.mjs").display()),
)
.env_remove("DATABASE_URL")
.env_remove("VERCEL")
.env_remove("NETLIFY")
.env_remove("CF_PAGES")
.env_remove("RAILWAY_ENVIRONMENT")
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr))
.spawn()
.expect("spawn failing boundary-property run");
let started = Instant::now();
let deadline = started + Duration::from_secs(120);
let status = loop {
match child.try_wait().expect("poll boundary-property run") {
Some(status) => break status,
None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
None => {
let _ = child.kill();
let _ = child.wait();
panic!("60 failing boundary properties did not finish within 120s");
}
}
};
let report = fs::read_to_string(&stdout_path).expect("read scenario report");
let errors = fs::read_to_string(&stderr_path).expect("read scenario errors");
assert!(
!status.success(),
"failing properties must fail the run: {report}"
);
assert!(
report.contains("\"total\":60") && report.contains("\"failed\":60"),
"all failing properties must reach the report: {report}\n{errors}"
);
assert!(
report.len() > 64 * 1024,
"the regression test must exceed one pipe buffer; report was {} bytes",
report.len()
);
assert!(
!errors.contains("SCENARIO_TIMEOUT_EXCEEDED"),
"a large completed report is not a scenario timeout: {errors}"
);
assert!(
started.elapsed() < Duration::from_secs(120),
"the test's deadline must stay well above the real run time"
);
}