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

87 lines
3.9 KiB
JavaScript
Raw Normal View History

/**
* 契约漂移检查(任务 A1.7)。
*
* 三件事:
* 1. 事件与 HTTP 样例必须符合 contracts/schemas 下的 JSON Schema
* 2. 事件类型在 schema、TS 镜像、协议文档三处必须完全一致
* 3. 每种事件类型都要有样例,避免新增类型忘了配样例
*/
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 EVENTS_SCHEMA_ID = 'https://aps.local/contracts/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('; ');
test('事件样例符合 events.schema.json', async () => {
const ajv = await buildAjv();
const validate = ajv.getSchema(EVENTS_SCHEMA_ID);
assert.ok(validate, '未注册 events.schema.json');
const lines = (await readText('contracts/fixtures/events.jsonl')).split(/\r?\n/).filter((l) => l.trim());
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 schemaId = `https://aps.local/contracts/${testCase.schema}`;
const validate = ajv.getSchema(schemaId);
assert.ok(validate, `${name}:未注册 ${testCase.schema}`);
assert.ok(validate(testCase.value), `${name} 样例不符合 ${testCase.schema}:${formatErrors(validate.errors)}`);
}
});
test('事件类型在 schema、TS 与协议文档三处一致', async () => {
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]));
assert.ok(schemaTypes.size >= 11, `schema 里事件类型太少:${schemaTypes.size}`);
const diff = (a, b, label) => [...a].filter((t) => !b.has(t)).map((t) => `${label}:${t}`);
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 fixtureTypes = new Set(
(await readText('contracts/fixtures/events.jsonl'))
.split(/\r?\n/)
.filter((l) => l.trim())
.map((l) => JSON.parse(l).type),
);
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 missing = [...schemaTypes].filter((t) => !fixtureTypes.has(t));
assert.deepEqual(missing, [], `以下事件类型缺样例:${missing.join('、')}`);
});