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}"
);
}