aps-agent/scripts/check-contracts.test.mjs

150 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 契约漂移检查(任务 A1.7 / A1.8)。
*
* 五件事:
* 1. 事件与 HTTP 样例必须符合 contracts/schemas 下的 JSON Schema
* 2. 事件类型在 schema、TS 镜像、协议文档三处必须完全一致
* 3. 每种事件类型、每个 HTTP payload 都要有样例
* 4. 协议文档里的端点表必须与 contracts/protocol.ts 的 ENDPOINT_SPECS 一致
* 5. 文档与常量里提到的类型名必须真的在 protocol.ts 里导出
*/
import assert from 'node:assert/strict';
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import Ajv2020 from 'ajv/dist/2020.js';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const SCHEMA_DIR = join(ROOT, 'contracts', 'schemas');
const SCHEMA_BASE = 'https://aps.local/contracts/';
const EVENTS_SCHEMA_ID = `${SCHEMA_BASE}events.schema.json`;
const readText = (rel) => readFile(join(ROOT, rel), 'utf8');
async function buildAjv() {
const ajv = new Ajv2020({ allErrors: true, strict: false });
for (const name of await readdir(SCHEMA_DIR)) {
if (!name.endsWith('.json')) continue;
ajv.addSchema(JSON.parse(await readFile(join(SCHEMA_DIR, name), 'utf8')));
}
return ajv;
}
const formatErrors = (errors) => (errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`).join('; ');
const eventFixtures = (text) => text.split(/\r?\n/).filter((l) => l.trim());
/** 事件类型:schema 里的 const、TS 里的信封泛型、文档表格首列 */
async function eventTypeSets() {
const schemaTypes = new Set(
[...(await readText('contracts/schemas/events.schema.json')).matchAll(/"const":\s*"([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*)"/g)].map((m) => m[1]),
);
const tsTypes = new Set([...(await readText('contracts/events.ts')).matchAll(/EventEnvelope<'([^']+)'/g)].map((m) => m[1]));
const docTypes = new Set([...(await readText('docs/spec/protocol.md')).matchAll(/^\| `([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*)` \|/gm)].map((m) => m[1]));
return { schemaTypes, tsTypes, docTypes };
}
const diff = (a, b, label) => [...a].filter((t) => !b.has(t)).map((t) => `${label}:${t}`);
const normalizePath = (p) => p.replace(/\$\{[^}]*\}/g, '{}').replace(/\{[^}]*\}/g, '{}');
test('事件样例符合 events.schema.json', async () => {
const ajv = await buildAjv();
const validate = ajv.getSchema(EVENTS_SCHEMA_ID);
assert.ok(validate, '未注册 events.schema.json');
const lines = eventFixtures(await readText('contracts/fixtures/events.jsonl'));
assert.ok(lines.length >= 11, `样例太少:${lines.length}`);
for (const line of lines) {
const event = JSON.parse(line);
assert.ok(validate(event), `${event.type} 样例不符合 schema:${formatErrors(validate.errors)}`);
}
});
test('HTTP 样例符合各自 schema', async () => {
const ajv = await buildAjv();
const cases = JSON.parse(await readText('contracts/fixtures/http.json'));
for (const [name, testCase] of Object.entries(cases)) {
const validate = ajv.getSchema(SCHEMA_BASE + testCase.schema);
assert.ok(validate, `${name}:未注册 ${testCase.schema}`);
assert.ok(validate(testCase.value), `${name} 不符合 ${testCase.schema}:${formatErrors(validate.errors)}`);
}
});
test('事件类型在 schema、TS 与协议文档三处一致', async () => {
const { schemaTypes, tsTypes, docTypes } = await eventTypeSets();
assert.ok(schemaTypes.size >= 11, `schema 里事件类型太少:${schemaTypes.size}`);
const problems = [
...diff(schemaTypes, tsTypes, '只在 schema'),
...diff(tsTypes, schemaTypes, '只在 TS'),
...diff(schemaTypes, docTypes, '只在 schema(文档缺)'),
...diff(docTypes, schemaTypes, '只在文档'),
];
assert.deepEqual(problems, [], `事件类型不一致:\n${problems.join('\n')}`);
});
test('每种事件类型都有样例', async () => {
const { schemaTypes } = await eventTypeSets();
const fixtureTypes = new Set(eventFixtures(await readText('contracts/fixtures/events.jsonl')).map((l) => JSON.parse(l).type));
const missing = [...schemaTypes].filter((t) => !fixtureTypes.has(t));
assert.deepEqual(missing, [], `以下事件类型缺样例:${missing.join('、')}`);
});
test('每个 HTTP payload 类型都有样例', async () => {
const protocol = await readText('contracts/protocol.ts');
const exported = new Set([...protocol.matchAll(/^export (?:interface|type) (\w+)/gm)].map((m) => m[1]));
exported.delete('CapabilityId'); // 类型别名,不是 payload
exported.delete('EndpointSpec'); // 端点表结构,不是 payload
const cases = JSON.parse(await readText('contracts/fixtures/http.json'));
const covered = new Set(Object.values(cases).map((c) => c.type));
const missing = [...exported].filter((t) => !covered.has(t));
const extra = [...covered].filter((t) => !exported.has(t));
assert.deepEqual(missing, [], `以下类型缺样例:${missing.join('、')}`);
assert.deepEqual(extra, [], `样例里的类型不在 protocol.ts 导出中:${extra.join('、')}`);
});
test('协议文档端点表与 ENDPOINT_SPECS 一致', async () => {
const protocol = await readText('contracts/protocol.ts');
const specs = [...protocol.matchAll(/method:\s*'(GET|POST)',\s*path:\s*'([^']+)',\s*request:\s*'([^']*)',\s*response:\s*'([^']*)'/g)].map((m) => ({
method: m[1],
path: m[2],
request: m[3],
response: m[4],
}));
assert.ok(specs.length >= 12, `ENDPOINT_SPECS 条目太少:${specs.length}`);
const doc = await readText('docs/spec/protocol.md');
const rows = [...doc.matchAll(/^\|\s*(GET|POST)\s*\|\s*`([^`]+)`\s*\|([^|]*)\|([^|]*)\|/gm)].map((m) => ({
method: m[1],
path: m[2],
request: m[3].trim(),
response: m[4].trim(),
}));
assert.ok(rows.length >= 12, `文档端点表条目太少:${rows.length}`);
const key = (e) => `${e.method} ${normalizePath(e.path)}`;
const specKeys = new Set(specs.map(key));
const docKeys = new Set(rows.map(key));
const problems = [...diff(specKeys, docKeys, '只在 ENDPOINT_SPECS'), ...diff(docKeys, specKeys, '只在文档')];
assert.deepEqual(problems, [], `端点不一致:\n${problems.join('\n')}`);
// 文档里用反引号标出的类型名必须真的存在
const exported = new Set([...protocol.matchAll(/^export (?:interface|type) (\w+)/gm)].map((m) => m[1]));
const unknown = [];
for (const row of rows) {
for (const cell of [row.request, row.response]) {
const hit = cell.match(/^`([A-Za-z][A-Za-z0-9_]*)`$/);
if (hit && !exported.has(hit[1])) unknown.push(`${row.method} ${row.path} → ${hit[1]}`);
}
}
assert.deepEqual(unknown, [], `文档引用了未导出的类型:${unknown.join('、')}`);
});
test('ENDPOINTS 常量与 ENDPOINT_SPECS 路径一致', async () => {
const protocol = await readText('contracts/protocol.ts');
const block = protocol.match(/export const ENDPOINTS = \{([\s\S]*?)\n\} as const;/);
assert.ok(block, '未找到 ENDPOINTS 常量');
const literalPaths = new Set([...block[1].matchAll(/['`](\/v1[^'`]*)['`]/g)].map((m) => normalizePath(m[1])));
const specPaths = new Set([...protocol.matchAll(/path:\s*'([^']+)'/g)].map((m) => normalizePath(m[1])));
const problems = [...diff(literalPaths, specPaths, '只在 ENDPOINTS'), ...diff(specPaths, literalPaths, '只在 ENDPOINT_SPECS')];
assert.deepEqual(problems, [], `路径不一致:\n${problems.join('\n')}`);
});