noxid-cli 0.2.1

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-wo42-live-{label}-{}-{ordinal}",
            std::process::id()
        ));
        fs::create_dir_all(&root).expect("create WO-42 fixture");
        fs::write(
            root.join("Noxid.toml"),
            "[app]\nid = \"wo42_live\"\ntitle = \"WO-42 live\"\n[server]\nlive_driver = \"memory\"\nlive_coalescing_ms = 1\n",
        )
        .expect("write manifest");
        fs::write(root.join("package.json"), "{\"type\":\"module\"}\n")
            .expect("write package 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 WO-42 fixture")
    }

    fn graph(&self) -> Output {
        Command::new(env!("CARGO_BIN_EXE_noxid"))
            .arg("graph")
            .arg(&self.root)
            .output()
            .expect("graph WO-42 fixture")
    }

    fn node(&self, script: &str) -> Output {
        self.write("dist/live-check.mjs", script);
        Command::new("node")
            .arg("live-check.mjs")
            .current_dir(self.root.join("dist"))
            .output()
            .expect("run emitted WO-42 handler")
    }
}

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_success_publishes_after_validation_while_failures_and_reads_do_not() {
    let fixture = Fixture::new("endpoint");
    fixture.write(
        "server/api/save.post.nox",
        r#"resource Orders(): Array<Int> { live get { GET "/api/orders" } }
endpoint Save { body { valid: Boolean } result: Int }
"#,
    );
    fixture.write(
        "server/api/read.get.nox",
        r#"resource ReadModel(): Int { live get { GET "/api/read-model" } }
endpoint Read { result: Int }
"#,
    );
    fixture.write(
        "server/api/quiet.post.nox",
        r#"resource QuietModel(): Int { live get { GET "/api/quiet-model" } }
endpoint Quiet { result: Int invalidates none }
"#,
    );
    fixture.write(
        "server/host.js",
        r#"export const endpoints = Object.freeze({
  "endpoint:Save@1": async ({ valid }) => valid ? 7 : "invalid",
  "endpoint:Read@1": async () => 1,
  "endpoint:Quiet@1": async () => 1,
});
"#,
    );
    assert_success(&fixture.build(), "build live endpoint fixture");
    let node = fixture.node(
        r#"import { fetchEndpoint, __noxidLiveConnectionPrincipal, __noxidPubSubSubscribe } from "./server/handler.js";
const environmentA = { sessionId: "tenant-a" };
const environmentB = { sessionId: "tenant-b" };
const principalA = __noxidLiveConnectionPrincipal(Object.create(null), environmentA);
const principalB = __noxidLiveConnectionPrincipal(Object.create(null), environmentB);
const events = [];
const options = { schedule: (run) => { queueMicrotask(run); return 1; }, cancel: () => {} };
const stopOrdersA = await __noxidPubSubSubscribe("invalidation", "resource:Orders", principalA, (event) => events.push(`a:${event.semanticId}`), options);
const stopOrdersB = await __noxidPubSubSubscribe("invalidation", "resource:Orders", principalB, (event) => events.push(`b:${event.semanticId}`), options);
const stopRead = await __noxidPubSubSubscribe("invalidation", "resource:ReadModel", principalA, (event) => events.push(`a:${event.semanticId}`), options);
const stopQuiet = await __noxidPubSubSubscribe("invalidation", "resource:QuietModel", principalA, (event) => events.push(`a:${event.semanticId}`), options);
const request = (valid) => new Request("http://noxid.test/api/save", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ valid }) });
let response = await fetchEndpoint(request(false), environmentA);
if (response.status !== 500) throw new Error(`invalid result did not fail: ${response.status}`);
await new Promise((resolve) => setTimeout(resolve, 5));
if (events.length !== 0) throw new Error(`failed endpoint published ${events}`);
response = await fetchEndpoint(new Request("http://noxid.test/api/read"), environmentA);
if (!response.ok) throw new Error(`read failed: ${response.status}`);
await new Promise((resolve) => setTimeout(resolve, 5));
if (events.length !== 0) throw new Error(`read endpoint published ${events}`);
response = await fetchEndpoint(new Request("http://noxid.test/api/quiet", { method: "POST" }), environmentA);
if (!response.ok) throw new Error(`quiet mutation failed: ${response.status}`);
await new Promise((resolve) => setTimeout(resolve, 5));
if (events.length !== 0) throw new Error(`invalidates none published ${events}`);
response = await fetchEndpoint(request(true), environmentA);
if (!response.ok) throw new Error(`mutation failed: ${response.status} ${await response.text()}`);
await new Promise((resolve) => setTimeout(resolve, 5));
if (JSON.stringify(events) !== JSON.stringify(["a:resource:Orders"])) throw new Error(`wrong or cross-principal invalidations ${events}`);
await stopOrdersA(); await stopOrdersB(); await stopRead(); await stopQuiet();
"#,
    );
    assert_success(&node, "execute live endpoint publication");
}

#[test]
fn action_publication_intersects_wo28_targets_with_live_contracts() {
    let fixture = Fixture::new("action");
    fixture.write(
        "src/routes/+page.nox",
        r#"resource LiveRows(): Int { live get { GET "/api/live" } }
resource ColdRows(): Int { get { GET "/api/cold" } }
component LivePage {
  resources { liveRows = LiveRows() coldRows = ColdRows() }
  actions {
    server publish(): Int invalidates [LiveRows] { return 1 }
    server quiet(): Int invalidates [ColdRows] { return 1 }
  }
  view { <p>live</p> }
}
"#,
    );
    assert_success(&fixture.build(), "build live action fixture");
    let node = fixture.node(
        r#"import { fetch as handle, __noxidLiveConnectionPrincipal, __noxidPubSubSubscribe } from "./server/handler.js";
const principal = __noxidLiveConnectionPrincipal(Object.create(null), Object.create(null));
const events = [];
const options = { schedule: (run) => { queueMicrotask(run); return 1; }, cancel: () => {} };
const stopLive = await __noxidPubSubSubscribe("invalidation", "resource:LiveRows", principal, (event) => events.push(event.semanticId), options);
const stopCold = await __noxidPubSubSubscribe("invalidation", "resource:ColdRows", principal, (event) => events.push(event.semanticId), options);
async function call(name) {
  return handle(new Request(`http://noxid.test/_noxid/actions/${encodeURIComponent(`action:LivePage.${name}`)}`, {
    method: "POST", headers: { "content-type": "application/json", "x-noxid-route-id": "route:/" }, body: JSON.stringify({ arguments: {} }),
  }));
}
let response = await call("quiet");
if (!response.ok) throw new Error(`quiet failed: ${response.status} ${await response.text()}`);
await new Promise((resolve) => setTimeout(resolve, 5));
if (events.length !== 0) throw new Error(`non-live target published ${events}`);
response = await call("publish");
if (!response.ok) throw new Error(`publish failed: ${response.status} ${await response.text()}`);
await new Promise((resolve) => setTimeout(resolve, 5));
if (JSON.stringify(events) !== JSON.stringify(["resource:LiveRows"])) throw new Error(`wrong action invalidations ${events}`);
await stopLive(); await stopCold();
"#,
    );
    assert_success(&node, "execute live action publication");
}

#[test]
fn endpoint_invalidations_fail_closed_after_route_attachment() {
    for (label, file, source, code) in [
        (
            "read",
            "server/api/read.get.nox",
            "resource One(): Int { live get { GET \"/one\" } } endpoint Read { result: Int invalidates [One] }",
            "ENDPOINT_READ_INVALIDATION_UNSUPPORTED",
        ),
        (
            "ambiguous",
            "server/api/save.post.nox",
            "resource One(): Int { live get { GET \"/one\" } } resource Two(): Int { live get { GET \"/two\" } } endpoint Save { result: Int }",
            "AMBIGUOUS_ENDPOINT_RESOURCE_INVALIDATION",
        ),
    ] {
        let fixture = Fixture::new(label);
        fixture.write(file, source);
        let output = fixture.build();
        assert!(!output.status.success(), "{label} failed open");
        let stderr = String::from_utf8_lossy(&output.stderr);
        assert!(stderr.contains(code), "missing {code}:\n{stderr}");
        assert!(
            stderr.contains("invalidates"),
            "missing teaching text: {stderr}"
        );
        assert!(
            !fixture.root.join("dist").exists(),
            "partial output published"
        );
    }
}

#[test]
fn endpoint_graph_classification_waits_for_the_attached_route() {
    let fixture = Fixture::new("endpoint-graph");
    fixture.write(
        "server/contracts.nox",
        r#"type Marker { value: Int }
resource ImportedOnly(): Int { live get { GET "/imported" } }
"#,
    );
    fixture.write(
        "server/api/save.post.nox",
        r#"import { Marker } from "../contracts.nox"
resource Orders(): Int { live get { GET "/orders" } }
endpoint Save { result: Int }
"#,
    );
    fixture.write(
        "server/api/read.get.nox",
        r#"resource Snapshot(): Int { live get { GET "/snapshot" } }
endpoint Read { result: Int }
"#,
    );
    let output = fixture.graph();
    assert_success(&output, "graph routed live endpoints");
    let graph = String::from_utf8_lossy(&output.stdout);
    assert!(
        graph.contains(r#"{"from":"endpoint:Save@1","kind":"invalidates","to":"resource:Orders"}"#),
        "mutating endpoint lost its live edge:\n{graph}"
    );
    assert!(
        !graph.contains(r#"{"from":"endpoint:Read@1","kind":"invalidates""#),
        "read endpoint gained an invalidation edge:\n{graph}"
    );
    assert!(
        !graph.contains(
            r#"{"from":"endpoint:Save@1","kind":"invalidates","to":"resource:ImportedOnly"}"#
        ),
        "reachable import broadened the module-local candidate set:\n{graph}"
    );
}

#[test]
fn endpoint_and_queue_invalidation_diagnostics_are_exhaustive() {
    let source = noxid_source::SourceFile::new(
        noxid_source::SourceId(4200),
        "invalid.nox",
        r#"resource Live(): Int { live get { GET "/live" } }
resource Cold(): Int { get { GET "/cold" } }
endpoint Bad { result: Int invalidates [Live, Live, Cold, Missing] }
queue Work { payload {} retry: 1 backoff: 1s invalidates [] }
queue Work2 { payload {} retry: 1 backoff: 1s invalidates [Live, Live, Cold, Missing] }
resource Other(): Int { live get { GET "/other" } }
queue Ambiguous { payload {} retry: 1 backoff: 1s }
"#,
    );
    let compilation = noxid_compiler_core::compile(&source);
    let codes = compilation
        .diagnostics
        .iter()
        .map(|diagnostic| diagnostic.code)
        .collect::<std::collections::BTreeSet<_>>();
    for code in [
        "DUPLICATE_ENDPOINT_INVALIDATION_RESOURCE",
        "NON_LIVE_ENDPOINT_INVALIDATION_RESOURCE",
        "UNKNOWN_ENDPOINT_INVALIDATION_RESOURCE",
        "EMPTY_QUEUE_INVALIDATION_RESOURCES",
        "DUPLICATE_QUEUE_INVALIDATION_RESOURCE",
        "NON_LIVE_QUEUE_INVALIDATION_RESOURCE",
        "UNKNOWN_QUEUE_INVALIDATION_RESOURCE",
        "AMBIGUOUS_QUEUE_RESOURCE_INVALIDATION",
    ] {
        assert!(codes.contains(code), "missing {code}: {codes:?}");
    }
}

#[test]
fn live_contracts_and_queue_edges_remain_compiler_visible() {
    let source = noxid_source::SourceFile::new(
        noxid_source::SourceId(4201),
        "contracts.nox",
        r#"resource Jobs(): Int { live get { GET "/jobs" } }
resource Cold(): Int { get { GET "/cold" } }
queue Derived { payload {} retry: 1 backoff: 1s }
queue Explicit { payload {} retry: 1 backoff: 1s invalidates [Jobs] }
queue Quiet { payload {} retry: 1 backoff: 1s invalidates none }
"#,
    );
    let compilation = noxid_compiler_core::compile(&source);
    assert!(
        compilation.diagnostics.is_empty(),
        "{}",
        compilation.diagnostics_json()
    );
    assert!(compilation.program.resources[0].live);
    assert!(!compilation.program.resources[1].live);
    let jobs = noxid_ir::SemanticId::resource("Jobs");
    assert_eq!(
        compilation.program.queues[0].invalidation.resources,
        vec![jobs.clone()]
    );
    assert_eq!(
        compilation.program.queues[1].invalidation.resources,
        vec![jobs.clone()]
    );
    assert!(
        compilation.program.queues[2]
            .invalidation
            .resources
            .is_empty()
    );
    for queue in ["Derived", "Explicit"] {
        assert!(compilation.graph.edges.iter().any(|edge| {
            edge.from == noxid_ir::SemanticId::queue(queue)
                && edge.kind == noxid_graph::EdgeKind::Invalidates
                && edge.to == jobs
        }));
    }
    assert!(!compilation.graph.edges.iter().any(|edge| {
        edge.from == noxid_ir::SemanticId::queue("Quiet")
            && edge.kind == noxid_graph::EdgeKind::Invalidates
    }));
    assert!(compilation.resources.to_json().contains("\"live\":true"));
    let execution = compilation.execution.to_json();
    assert!(execution.contains("\"schemaVersion\": 18"), "{execution}");
    assert!(
        execution.contains(
            "\"liveResources\": [{\"id\":\"resource:Jobs\",\"name\":\"Jobs\",\"capabilities\":[],\"routeScopes\":[]}]"
        ),
        "{execution}"
    );
}