Add import-safe API/MCP contract tests
This commit is contained in:
parent
7b6b176b1b
commit
925f8072e9
@ -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,13 +227,16 @@ function tryParseBuffer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
process.stdin.on("data", (chunk) => {
|
const isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
|
||||||
|
if (isMain) {
|
||||||
|
process.stdin.on("data", (chunk) => {
|
||||||
stdinBuffer = Buffer.concat([stdinBuffer, Buffer.from(chunk)]);
|
stdinBuffer = Buffer.concat([stdinBuffer, Buffer.from(chunk)]);
|
||||||
tryParseBuffer();
|
tryParseBuffer();
|
||||||
});
|
});
|
||||||
|
|
||||||
process.stdin.on("error", (err) => {
|
process.stdin.on("error", (err) => {
|
||||||
process.stderr.write(`stdin error: ${String(err)}\n`);
|
process.stderr.write(`stdin error: ${String(err)}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
85
tests/api-contract.test.js
Normal file
85
tests/api-contract.test.js
Normal 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"));
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user