noxid-cli 0.2.1

The Noxid compiler command line: check, build, test, adapt, and the agent surface
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"
    );
}