noxid-cli 0.2.0

The Noxid compiler command line: check, build, test, adapt, and the agent surface
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};

static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);

struct Fixture {
    root: PathBuf,
}

impl Fixture {
    fn new(label: &str) -> Self {
        let ordinal = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed);
        let root = std::env::temp_dir().join(format!(
            "noxid-wo19-endpoint-cache-{label}-{}-{ordinal}",
            std::process::id()
        ));
        fs::create_dir_all(&root).expect("create endpoint-cache fixture");
        fs::write(
            root.join("Noxid.toml"),
            "[app]\ntitle = \"Endpoint cache\"\n",
        )
        .expect("write project config");
        fs::write(root.join("package.json"), "{\"type\":\"module\"}\n")
            .expect("write module marker");
        Self { root }
    }

    fn write(&self, relative: &str, contents: &str) {
        let path = self.root.join(relative);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("create fixture parent");
        }
        fs::write(path, contents).expect("write fixture file");
    }

    fn build(&self) -> Output {
        Command::new(env!("CARGO_BIN_EXE_noxid"))
            .arg("build")
            .arg(&self.root)
            .arg("--out-dir")
            .arg(self.root.join("dist"))
            .output()
            .expect("build endpoint-cache fixture")
    }

    fn run_node(&self, source: &str) -> Output {
        let script = self.root.join("dist/assert-cache.mjs");
        fs::write(&script, source).expect("write endpoint-cache Node assertion");
        Command::new("node")
            .arg(script.file_name().expect("script filename"))
            .current_dir(self.root.join("dist"))
            .output()
            .expect("run endpoint-cache Node assertion")
    }
}

impl Drop for Fixture {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.root);
    }
}

fn assert_success(output: &Output, context: &str) {
    assert!(
        output.status.success(),
        "{context}:\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn endpoint_cache_is_declared_in_manifests_and_emitted_on_success_only() {
    let fixture = Fixture::new("swr");
    fixture.write(
        "server/api/catalog.get.nox",
        r#"endpoint Catalog {
    cache: swr 60
    result: String
    handler { return "catalog" }
}
"#,
    );
    fixture.write(
        "server/host.js",
        r#"export async function authorize({ capability }) {
  return capability === "cache.invalidate";
}
export async function invalidateCache(tags, { environment }) {
  environment.invalidations.push(...tags);
  return { invalidated: tags.length };
}
"#,
    );

    let build = fixture.build();
    assert_success(&build, "build cached GET endpoint");

    let security = fs::read_to_string(fixture.root.join("dist/server/security.manifest.json"))
        .expect("read endpoint security manifest");
    assert!(
        security.contains(
            "\"cache\":{\"id\":\"endpoint-cache:Catalog\",\"mode\":\"swr\",\"seconds\":60,\"tags\":[\"endpoint:Catalog@1\"]}"
        ),
        "cache policy missing from security product: {security}"
    );
    let graph = fs::read_to_string(fixture.root.join("dist/app.graph.json"))
        .expect("read application graph");
    assert!(
        graph.contains(
            "{\"from\":\"endpoint:Catalog@1\",\"kind\":\"caches-as\",\"to\":\"endpoint-cache:Catalog\"}"
        ),
        "cache edge missing from application graph: {graph}"
    );

    let node = fixture.run_node(
        r#"import { fetch as handle } from "./server/handler.js";
const environment = { invalidations: [] };
let response = await handle(new Request("http://noxid.test/api/catalog"), environment);
if (response.status !== 200) throw new Error(`success changed: ${response.status}`);
if (response.headers.get("cache-control") !== "public, s-maxage=60, stale-while-revalidate=60") throw new Error(`cache-control missing: ${response.headers.get("cache-control")}`);
if (response.headers.get("x-noxid-cache-mode") !== "swr") throw new Error("mode missing");
if (response.headers.get("x-noxid-cache-tags") !== "endpoint:Catalog@1") throw new Error("stable tag missing");
response = await handle(new Request("http://noxid.test/_noxid/revalidate", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ tags: ["endpoint:Catalog@1"] }),
}), environment);
if (response.status !== 200 || environment.invalidations.join(",") !== "endpoint:Catalog@1") throw new Error(`revalidation failed: ${response.status} ${environment.invalidations}`);
"#,
    );
    assert_success(&node, "exercise emitted endpoint cache contract");
}

#[test]
fn endpoint_cache_rejects_mutating_routes_before_emission() {
    let fixture = Fixture::new("post");
    fixture.write(
        "server/api/catalog.post.nox",
        r#"endpoint Catalog {
    cache: isr 300
    result: String
}
"#,
    );
    let build = fixture.build();
    assert!(
        !build.status.success(),
        "mutating cached endpoint unexpectedly built"
    );
    let stderr = String::from_utf8_lossy(&build.stderr);
    assert!(
        stderr.contains("error[ENDPOINT_CACHE_REQUIRES_GET]")
            && stderr.contains("only valid for GET endpoints"),
        "mutating cache failure did not teach the legal alternative: {stderr}"
    );
    assert!(
        !fixture.root.join("dist/server/handler.js").exists(),
        "invalid cached endpoint emitted a handler"
    );
}

#[test]
fn cached_host_analysis_ignores_inert_handler_decoys() {
    let fixture = Fixture::new("host-decoys");
    fixture.write(
        "server/api/viewer.get.nox",
        "endpoint Viewer { cache: swr 60 result: String }\n",
    );
    fixture.write(
        "server/host.js",
        r#"// "endpoint:Viewer@1": () => "comment";
const stringDecoy = '"endpoint:Viewer@1": () => "string"';
const templateDecoy = `"endpoint:Viewer@1": () => "template"`;
export const endpoints = Object.freeze({
  "endpoint:Viewer@1": (_input, { environment }) => environment.viewer,
});
void stringDecoy;
void templateDecoy;
"#,
    );

    let build = fixture.build();
    assert!(
        !build.status.success(),
        "inert host decoys admitted public caching"
    );
    let stderr = String::from_utf8_lossy(&build.stderr);
    assert!(
        stderr.contains("error[ENDPOINT_CACHE_HOST_HANDLER_UNANALYZABLE]"),
        "host decoy refusal lacked its teaching diagnostic: {stderr}"
    );
    assert!(
        !fixture.root.join("dist/server/handler.js").exists(),
        "rejected personalized cache handler was published"
    );
}