use std::fs;
use std::path::{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-wo23-{label}-{}-{ordinal}",
std::process::id()
));
fs::create_dir_all(&root).unwrap();
fs::write(
root.join("Noxid.toml"),
"[app]\ntitle = \"WO-23 fixture\"\n\n[server]\napi_docs = true\nmcp = true\n",
)
.unwrap();
fs::write(root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
Self { root }
}
fn path(&self, relative: &str) -> PathBuf {
self.root.join(relative)
}
fn write(&self, relative: &str, contents: &str) {
let path = self.path(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
fn gate(&self) -> Output {
Command::new(env!("CARGO_BIN_EXE_noxid"))
.args(["test", ".", "--gate"])
.current_dir(&self.root)
.output()
.unwrap()
}
fn build(&self) -> Output {
Command::new(env!("CARGO_BIN_EXE_noxid"))
.args(["build", ".", "--out-dir", "dist"])
.current_dir(&self.root)
.output()
.unwrap()
}
}
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)
);
}
fn baseline_sources(fixture: &Fixture) {
fixture.write(
"server/api/save.post.nox",
r#"endpoint Save {
version: 1
body { value: Number removed: String }
result: Number
handler { return value }
}
"#,
);
fixture.write(
"server/api/changed.post.nox",
"endpoint Changed { version: 1 result: String handler { return \"ok\" } }\n",
);
fixture.write(
"server/api/removed.get.nox",
"endpoint Removed { version: 1 result: String handler { return \"ok\" } }\n",
);
}
#[test]
fn gate_names_every_breaking_class_and_version_bumps_accept_changes() {
let fixture = Fixture::new("breaking");
baseline_sources(&fixture);
assert_success(&fixture.gate(), "initialize committed contract");
let baseline = fs::read_to_string(fixture.path("api-contract.json")).unwrap();
fs::remove_file(fixture.path("server/api/removed.get.nox")).unwrap();
fs::rename(
fixture.path("server/api/changed.post.nox"),
fixture.path("server/api/moved.put.nox"),
)
.unwrap();
fixture.write(
"server/api/save.post.nox",
r#"endpoint Save {
version: 1
body { value: Int required: Boolean }
result: Int
handler { return value }
}
"#,
);
let failed = fixture.gate();
assert!(
!failed.status.success(),
"breaking gate unexpectedly passed"
);
let stderr = String::from_utf8_lossy(&failed.stderr);
for expected in [
"error[API_CONTRACT_BREAKING_CHANGE]",
"removed endpoint `Removed`",
"method changed from POST to PUT",
"path changed from `/api/changed` to `/api/moved`",
"removed body field `removed`",
"narrowed body.value type from `Number` to `Int`",
"added required body field `required`",
"narrowed result type from `Number` to `Int`",
] {
assert!(stderr.contains(expected), "missing {expected}:\n{stderr}");
}
assert_eq!(
fs::read_to_string(fixture.path("api-contract.json")).unwrap(),
baseline,
"failed gate rewrote the committed contract"
);
fixture.write(
"server/api/removed.get.nox",
"endpoint Removed { version: 1 result: String handler { return \"ok\" } }\n",
);
fixture.write(
"server/api/moved.put.nox",
"endpoint Changed { version: 2 result: String handler { return \"ok\" } }\n",
);
fixture.write(
"server/api/save.post.nox",
r#"endpoint Save {
version: 2
body { value: Int required: Boolean }
result: Int
handler { return value }
}
"#,
);
assert_success(&fixture.gate(), "accept versioned breaking changes");
let accepted = fs::read_to_string(fixture.path("api-contract.json")).unwrap();
assert_ne!(accepted, baseline);
assert!(accepted.contains("\"semanticId\":\"endpoint:Save@2\""));
assert!(accepted.contains("\"semanticId\":\"endpoint:Changed@2\""));
}
#[test]
fn additive_changes_update_only_after_a_passing_gate_and_match_build_output() {
let fixture = Fixture::new("additive");
fixture.write(
"server/api/value.post.nox",
"endpoint Value { body { value: Int } result: Int handler { return value } }\n",
);
assert_success(&fixture.gate(), "initialize additive contract");
let baseline = fs::read_to_string(fixture.path("api-contract.json")).unwrap();
fixture.write(
"server/api/value.post.nox",
r#"endpoint Value {
body { value: Int note: Optional<String> }
result: Number
handler { return value }
scenario Broken {
description: "contract updates wait for the complete gate"
when: request(body: Shape(value = 1, note = None))
expect: 2
}
}
"#,
);
let scenario_failure = fixture.gate();
assert!(!scenario_failure.status.success());
assert_eq!(
fs::read_to_string(fixture.path("api-contract.json")).unwrap(),
baseline,
"a failed scenario published an additive contract"
);
fixture.write(
"server/api/value.post.nox",
"endpoint Value { body { value: Int note: Optional<String> } result: Number handler { return value } }\n",
);
fixture.write(
"server/api/new.get.nox",
"endpoint NewValue { result: String handler { return \"new\" } }\n",
);
assert_success(&fixture.gate(), "accept additive contract changes");
let committed = fs::read_to_string(fixture.path("api-contract.json")).unwrap();
assert_ne!(committed, baseline);
assert!(committed.contains("\"name\":\"NewValue\""));
assert!(committed.contains("Optional<String>"));
assert_success(&fixture.build(), "build additive fixture");
assert_eq!(
committed,
fs::read_to_string(fixture.path("dist/api-contract.json")).unwrap(),
"committed gate contract differs from fresh build artifact"
);
}
#[test]
fn version_is_visible_in_semantic_openapi_mcp_and_security_surfaces() {
let fixture = Fixture::new("surfaces");
fixture.write(
"server/api/status.get.nox",
"endpoint Status { version: 7 description: \"Versioned status.\" result: String handler { return \"ready\" } }\n",
);
assert_success(&fixture.build(), "build versioned surfaces");
for (relative, needles) in [
(
"dist/api.openapi.json",
vec![
"\"x-noxid-endpoint-id\": \"endpoint:Status@7\"",
"\"x-noxid-version\": 7",
],
),
(
"dist/app.semantic-units.json",
vec!["\"id\":\"endpoint:Status@7\"", "\"version\":7"],
),
(
"dist/server/security.manifest.json",
vec!["\"id\":\"endpoint:Status@7\"", "\"version\":7"],
),
] {
let text = fs::read_to_string(fixture.path(relative)).unwrap();
for needle in needles {
assert!(
text.contains(needle),
"{relative} omitted {needle}:\n{text}"
);
}
}
fixture.write(
"dist/versioned.mjs",
r#"import { fetch as handle } from "./server/handler.js";
const request = new Request("http://noxid.test/_noxid/mcp", {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
});
const rpc = await (await handle(request)).json();
const tool = rpc.result.tools[0];
if (tool.name !== "Status" || tool["x-noxid-endpoint"].semanticId !== "endpoint:Status@7" || tool["x-noxid-endpoint"].version !== 7) throw new Error(JSON.stringify(tool));
"#,
);
let node = Command::new("node")
.arg("versioned.mjs")
.current_dir(fixture.path("dist"))
.output()
.unwrap();
assert_success(&node, "inspect MCP version metadata");
}
#[test]
fn website_committed_contract_matches_its_fresh_build() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let website = root.join("website");
let output = Command::new(env!("CARGO_BIN_EXE_noxid"))
.args(["build", ".", "--out-dir", "dist"])
.current_dir(&website)
.output()
.unwrap();
assert_success(&output, "build website contract dogfood");
assert_eq!(
fs::read(website.join("api-contract.json")).unwrap(),
fs::read(website.join("dist/api-contract.json")).unwrap(),
"website committed contract is stale"
);
}