Add import-safe API/MCP contract tests

This commit is contained in:
Rbanh 2026-02-18 20:40:56 -05:00
parent 7b6b176b1b
commit 925f8072e9
3 changed files with 124 additions and 23 deletions

View File

@ -1,4 +1,5 @@
import { compile, analyze } from "./compile.js"; import { compile, analyze } from "./compile.js";
import { pathToFileURL } from "node:url";
const API_VERSION = "0.3.0"; const API_VERSION = "0.3.0";
const SCHEMA_VERSION = "1.0.0"; const SCHEMA_VERSION = "1.0.0";
@ -38,7 +39,7 @@ function decodePayload(value) {
throw new Error("payload must be a JSON object or JSON string"); throw new Error("payload must be a JSON object or JSON string");
} }
function toolListResult() { export function toolListResult() {
return { return {
tools: [ tools: [
{ {
@ -89,7 +90,7 @@ function toolListResult() {
}; };
} }
function withEnvelopeMeta(payload) { export function withEnvelopeMeta(payload) {
return { return {
api_version: API_VERSION, api_version: API_VERSION,
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
@ -97,7 +98,7 @@ function withEnvelopeMeta(payload) {
}; };
} }
function uiBundleDescriptor() { export function uiBundleDescriptor() {
return { return {
name: "schemeta-workspace", name: "schemeta-workspace",
version: "0.2.0", version: "0.2.0",
@ -109,7 +110,7 @@ function uiBundleDescriptor() {
}; };
} }
function handleToolCall(name, args) { export function handleToolCall(name, args) {
if (!args || typeof args !== "object") { if (!args || typeof args !== "object") {
args = {}; args = {};
} }
@ -146,7 +147,7 @@ function handleToolCall(name, args) {
throw new Error(`Unknown tool '${name}'`); throw new Error(`Unknown tool '${name}'`);
} }
function handleRequest(message) { export function handleRequest(message) {
const { id, method, params } = message; const { id, method, params } = message;
if (method === "initialize") { if (method === "initialize") {
@ -226,6 +227,8 @@ function tryParseBuffer() {
} }
} }
const isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
if (isMain) {
process.stdin.on("data", (chunk) => { process.stdin.on("data", (chunk) => {
stdinBuffer = Buffer.concat([stdinBuffer, Buffer.from(chunk)]); stdinBuffer = Buffer.concat([stdinBuffer, Buffer.from(chunk)]);
tryParseBuffer(); tryParseBuffer();
@ -236,3 +239,4 @@ process.stdin.on("error", (err) => {
}); });
process.stdin.resume(); process.stdin.resume();
}

View File

@ -1,6 +1,7 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { extname, join, normalize } from "node:path"; import { extname, join, normalize } from "node:path";
import { pathToFileURL } from "node:url";
import { analyze, compile } from "./compile.js"; import { analyze, compile } from "./compile.js";
import { applyLayoutToModel } from "./layout.js"; import { applyLayoutToModel } from "./layout.js";
import { validateModel } from "./validate.js"; import { validateModel } from "./validate.js";
@ -10,8 +11,8 @@ const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES ?? 2 * 1024 * 1024);
const CORS_ORIGIN = process.env.CORS_ORIGIN ?? "*"; const CORS_ORIGIN = process.env.CORS_ORIGIN ?? "*";
const MAX_REQUESTS_PER_MINUTE = Number(process.env.MAX_REQUESTS_PER_MINUTE ?? 120); const MAX_REQUESTS_PER_MINUTE = Number(process.env.MAX_REQUESTS_PER_MINUTE ?? 120);
const FRONTEND_ROOT = join(process.cwd(), "frontend"); const FRONTEND_ROOT = join(process.cwd(), "frontend");
const API_VERSION = "0.3.0"; export const API_VERSION = "0.3.0";
const SCHEMA_VERSION = "1.0.0"; export const SCHEMA_VERSION = "1.0.0";
const RATE_WINDOW_MS = 60_000; const RATE_WINDOW_MS = 60_000;
const rateByClient = new Map(); const rateByClient = new Map();
@ -36,7 +37,7 @@ function json(res, status, payload) {
res.end(JSON.stringify(payload, null, 2)); res.end(JSON.stringify(payload, null, 2));
} }
function errorEnvelope(code, message) { export function errorEnvelope(code, message) {
return { return {
ok: false, ok: false,
error: { error: {
@ -114,7 +115,7 @@ async function serveStatic(urlPath, res) {
} }
} }
function parsePayloadOptions(body) { export function parsePayloadOptions(body) {
if (body && typeof body === "object" && Object.prototype.hasOwnProperty.call(body, "payload")) { if (body && typeof body === "object" && Object.prototype.hasOwnProperty.call(body, "payload")) {
return { return {
payload: body.payload, payload: body.payload,
@ -128,7 +129,7 @@ function parsePayloadOptions(body) {
}; };
} }
function withEnvelopeMeta(payload) { export function withEnvelopeMeta(payload) {
return { return {
api_version: API_VERSION, api_version: API_VERSION,
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
@ -172,7 +173,8 @@ function withinRateLimit(req) {
return true; return true;
} }
const server = createServer(async (req, res) => { export function createRequestHandler() {
return async (req, res) => {
if (!req.url || !req.method) { if (!req.url || !req.method) {
return json(res, 400, errorEnvelope("invalid_request", "Invalid request.")); return json(res, 400, errorEnvelope("invalid_request", "Invalid request."));
} }
@ -312,8 +314,18 @@ const server = createServer(async (req, res) => {
} }
return json(res, 404, errorEnvelope("not_found", "Not found.")); return json(res, 404, errorEnvelope("not_found", "Not found."));
}); };
}
server.listen(PORT, () => { export const server = createServer(createRequestHandler());
console.log(`Schemeta server listening on http://localhost:${PORT}`);
export function startServer(port = PORT) {
server.listen(port, () => {
console.log(`Schemeta server listening on http://localhost:${port}`);
}); });
}
const isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
if (isMain) {
startServer(PORT);
}

View File

@ -0,0 +1,85 @@
import test from "node:test";
import assert from "node:assert/strict";
import fixture from "../examples/esp32-audio.json" with { type: "json" };
import { compile, analyze } from "../src/compile.js";
import {
API_VERSION as REST_API_VERSION,
SCHEMA_VERSION as REST_SCHEMA_VERSION,
withEnvelopeMeta as withRestEnvelopeMeta,
parsePayloadOptions,
errorEnvelope
} from "../src/server.js";
import {
handleToolCall,
uiBundleDescriptor,
toolListResult
} from "../src/mcp-server.js";
test("REST compile contract shape is stable with version metadata", () => {
const parsed = parsePayloadOptions({ payload: fixture, options: { render_mode: "schematic_stub" } });
const body = withRestEnvelopeMeta(compile(parsed.payload, parsed.options));
assert.equal(body.ok, true);
assert.equal(body.api_version, REST_API_VERSION);
assert.equal(body.schema_version, REST_SCHEMA_VERSION);
assert.ok(Array.isArray(body.errors));
assert.ok(Array.isArray(body.warnings));
assert.ok(Array.isArray(body.bus_groups));
assert.equal(typeof body.render_mode_used, "string");
assert.equal(typeof body.svg, "string");
});
test("REST analyze contract shape is stable with version metadata", () => {
const parsed = parsePayloadOptions({ payload: fixture });
const body = withRestEnvelopeMeta(analyze(parsed.payload, parsed.options));
assert.equal(body.ok, true);
assert.equal(body.api_version, REST_API_VERSION);
assert.equal(body.schema_version, REST_SCHEMA_VERSION);
assert.ok(body.topology);
assert.ok(Array.isArray(body.topology.power_domains));
assert.ok(Array.isArray(body.topology.signal_paths));
});
test("REST error envelope exposes stable code/message fields", () => {
const body = errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.");
assert.equal(body.ok, false);
assert.equal(body.error.code, "rate_limited");
assert.equal(typeof body.error.message, "string");
});
test("MCP schemeta_compile returns structured content with version metadata", () => {
const result = handleToolCall("schemeta_compile", { payload: fixture });
const body = result.structuredContent;
assert.equal(result.isError, false);
assert.equal(body.ok, true);
assert.equal(typeof body.api_version, "string");
assert.equal(typeof body.schema_version, "string");
assert.equal(typeof body.svg, "string");
});
test("MCP schemeta_analyze returns structured topology fields", () => {
const result = handleToolCall("schemeta_analyze", { payload: fixture });
const body = result.structuredContent;
assert.equal(result.isError, false);
assert.equal(body.ok, true);
assert.equal(typeof body.api_version, "string");
assert.equal(typeof body.schema_version, "string");
assert.ok(body.topology);
assert.ok(Array.isArray(body.topology.power_domains));
});
test("MCP UI bundle and tool list contracts remain stable", () => {
const ui = uiBundleDescriptor();
assert.equal(ui.transport, "iframe");
assert.equal(typeof ui.api_version, "string");
assert.equal(typeof ui.schema_version, "string");
const tools = toolListResult();
assert.ok(Array.isArray(tools.tools));
assert.ok(tools.tools.some((t) => t.name === "schemeta_compile"));
assert.ok(tools.tools.some((t) => t.name === "schemeta_analyze"));
assert.ok(tools.tools.some((t) => t.name === "schemeta_ui_bundle"));
});