aps-agent/server/provider-sdk/contract-suite.ts

89 lines
4.2 KiB
TypeScript
Raw Permalink 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.

/**
* Provider 契约测试套件(任务 A2.4)。
*
* 任何 Provider 接入前都要跑这一套:能力声明是否合法、健康探测是否可用、未知能力是否报错。
* 覆盖实施设计 5 的要求:Schema 校验、幂等重放、权限拒绝、错误映射、超时与结果未知、异步受理与查询
* ——其中幂等、超时与结果未知由 Core 侧统一保证(见 scripts/core-invoke.test.mjs),
* 这里只测 Provider 自己必须满足的部分。
*/
import { ERROR_KINDS, ProviderError, isErrorKind } from './errors.js';
import type { Capability, Provider } from './index.js';
export interface ContractCheck {
name: string;
ok: boolean;
detail?: string;
}
export interface ContractReport {
providerId: string;
ok: boolean;
checks: ContractCheck[];
}
const ID_PATTERN = /^[a-z0-9]+(\.[a-z0-9-]+)+$/;
function checkCapability(capability: Capability): string[] {
const problems: string[] = [];
if (!ID_PATTERN.test(capability.id)) problems.push('id 命名不合规');
if (!capability.description || capability.description.trim() === '') problems.push('缺 description');
if (capability.description.length > 40) problems.push('description 超过 40 字');
if (!['read', 'draft', 'write'].includes(capability.effect)) problems.push('effect 非法');
if (!['sync', 'async'].includes(capability.mode)) problems.push('mode 非法');
if (!['none', 'key', 'natural'].includes(capability.idempotency)) problems.push('idempotency 非法');
if (typeof capability.inputSchema !== 'object' || capability.inputSchema === null) problems.push('缺 inputSchema');
if (typeof capability.outputSchema !== 'object' || capability.outputSchema === null) problems.push('缺 outputSchema');
if (!Array.isArray(capability.errors) || capability.errors.length === 0) problems.push('errors 不能为空');
else if (!capability.errors.every(isErrorKind)) problems.push('errors 含非法分类');
if (capability.effect === 'write' && capability.idempotency === 'none') problems.push('write 能力必须声明幂等方式');
return problems;
}
export async function runProviderContract(provider: Provider): Promise<ContractReport> {
const checks: ContractCheck[] = [];
const record = (name: string, ok: boolean, detail?: string) => {
checks.push(detail === undefined ? { name, ok } : { name, ok, detail });
};
let providerId = '(未知)';
try {
const manifest = await provider.describe();
providerId = manifest.id;
record('describe 返回 manifest', true);
record('manifest.id 非空', typeof manifest.id === 'string' && manifest.id.trim() !== '');
record('platformVersion 兼容', manifest.platformVersion === 1, `platformVersion=${manifest.platformVersion}`);
record('capabilities 非空', Array.isArray(manifest.capabilities) && manifest.capabilities.length > 0);
let bad = 0;
for (const capability of manifest.capabilities ?? []) {
const problems = checkCapability(capability);
if (problems.length > 0) {
bad += 1;
record(`能力 ${capability.id} 声明合法`, false, problems.join(';'));
}
}
if (bad === 0) record('全部能力声明合法', true);
} catch (error) {
record('describe 返回 manifest', false, error instanceof Error ? error.message : '未知错误');
}
try {
const health = await provider.health();
record('health 返回合法状态', ['ok', 'degraded', 'down'].includes(health.status), `status=${health.status}`);
} catch (error) {
record('health 返回合法状态', false, error instanceof Error ? error.message : '未知错误');
}
try {
await provider.invoke(
{ executionId: 'contract-probe', sessionId: 'contract', projectId: null, subject: { userId: 'contract', tenantId: 'contract' }, deadlineAt: Date.now() + 5_000 },
{ capabilityId: `${providerId}.__not_registered__`, params: {} },
);
record('未知能力必须报错', false, '未抛错');
} catch (error) {
record('未知能力必须报错', error instanceof ProviderError && error.kind === 'invalid_argument', error instanceof Error ? error.message : '未知错误');
}
record('错误分类集合覆盖六类', ERROR_KINDS.length === 6);
return { providerId, ok: checks.every((check) => check.ok), checks };
}