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

87 lines
3.9 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)。
*
* 三件事:
* 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('、')}`);
});