aps-agent/scripts/server-smoke.test.mjs

254 lines
11 KiB
JavaScript
Raw Normal View History

/**
* 平台服务冒烟测试(任务 A1.3)。
*
* 打通「建会话 → 发消息 → 事件流推送」这条链路,并顺带验证协议里写死的几条规则:
* 1. 未接入任何 Provider 时平台也能正常启动(ADR-0009)
* 2. 事件符合 contracts/schemas/events.schema.json,seq 会话内单调递增
* 3. 同一幂等键重试不产生第二个 run
* 4. 取消能把运行停下,并推 cancelling → completed(cancelled) → idle
* 5. 断点续传:since 能回放
* 6. 错误走 ErrorEnvelope;个人会话不允许带 projectId
*/
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 EVENTS_SCHEMA_ID = 'https://aps.local/contracts/events.schema.json';
async function validator() {
const ajv = new Ajv2020({ allErrors: true, strict: false });
for (const name of await readdir(join(ROOT, 'contracts', 'schemas'))) {
if (!name.endsWith('.json')) continue;
ajv.addSchema(JSON.parse(await readFile(join(ROOT, 'contracts', 'schemas', name), 'utf8')));
}
return ajv.getSchema(EVENTS_SCHEMA_ID);
}
async function startServer(options = {}) {
const { startServer: start } = await import('../server/dist/index.js');
return start({ host: '127.0.0.1', port: 0, version: 'test', databasePath: ':memory:', engine: 'fake', ...options });
}
const json = (method, body) => ({ method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const post = (base, path, body) => fetch(`${base}${path}`, json('POST', body));
const get = (base, path) => fetch(`${base}${path}`);
async function openStream(base, sessionId, query = '') {
const controller = new AbortController();
const response = await fetch(`${base}/v1/sessions/${sessionId}/events${query}`, {
headers: { accept: 'text/event-stream' },
signal: controller.signal,
});
assert.equal(response.status, 200, '事件流应返回 200');
const events = [];
void (async () => {
const decoder = new TextDecoder();
let buffer = '';
try {
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
let index = buffer.indexOf('\n\n');
while (index >= 0) {
const frame = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
const dataLine = frame.split('\n').find((line) => line.startsWith('data: '));
if (dataLine) events.push(JSON.parse(dataLine.slice(6)));
index = buffer.indexOf('\n\n');
}
}
} catch {
/* aborted */
}
})();
return {
events,
close: () => controller.abort(),
async waitFor(predicate, label, timeoutMs = 6000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const hit = predicate(events);
if (hit) return hit;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(`超时等待「${label}」;已收到:${events.map((event) => event.type).join(',') || '(无)'}`);
},
};
}
const findIdleAfterCompleted = (events) =>
events.some((event) => event.type === 'message.completed') && events.some((event) => event.type === 'run.state' && event.data.state === 'idle');
async function createProjectSession(base, title = '冒烟会话') {
const response = await post(base, '/v1/sessions', { scope: 'project', projectId: 'p-default', title });
assert.equal(response.status, 200);
const body = await response.json();
return body.session;
}
test('未接入业务系统时平台可用:健康检查、身份、项目、能力清单', async () => {
const server = await startServer();
try {
const health = await (await get(server.url, '/v1/health')).json();
assert.equal(health.status, 'ok');
assert.equal(health.protocolVersion, 1);
assert.equal(typeof health.version, 'string');
const me = await (await get(server.url, '/v1/me')).json();
assert.equal(me.userId, 'dev-user');
assert.equal(me.clientKind, 'browser');
const projects = await (await get(server.url, '/v1/projects')).json();
assert.ok(Array.isArray(projects.projects) && projects.projects.length >= 1, '应有默认项目');
const capabilities = await (await get(server.url, '/v1/capabilities')).json();
assert.ok(Array.isArray(capabilities.capabilities), '能力清单应是数组');
assert.equal(capabilities.capabilities.length, 0, '未接入 Provider 时能力清单为空');
} finally {
await server.close();
}
});
test('建会话 → 发消息 → 事件流按协议推送且样例合规', async () => {
const validateEvent = await validator();
assert.ok(validateEvent);
const server = await startServer();
try {
const session = await createProjectSession(server.url);
const stream = await openStream(server.url, session.sessionId);
const accepted = await post(server.url, `/v1/sessions/${session.sessionId}/messages`, {
text: '试排一版看看',
idempotencyKey: 'idem-smoke-0001',
});
assert.equal(accepted.status, 202);
const acceptedBody = await accepted.json();
assert.equal(acceptedBody.accepted, true);
assert.ok(acceptedBody.runId.startsWith('r-'));
await stream.waitFor(findIdleAfterCompleted, '回合结束');
stream.close();
const types = stream.events.map((event) => event.type);
assert.equal(types[0], 'message.user', `第一条应为 message.user,实际 ${types[0]}`);
assert.ok(types.includes('message.delta'), '应收到增量文本');
assert.ok(types.includes('run.state'), '应收到运行状态');
let lastSeq = 0;
for (const event of stream.events) {
assert.ok(validateEvent(event), `${event.type} 不符合 events.schema.json:${JSON.stringify(validateEvent.errors)}`);
assert.equal(event.v, 1);
assert.ok(event.seq > lastSeq, 'seq 必须严格递增');
lastSeq = event.seq;
assert.equal(event.sessionId, session.sessionId);
}
const detail = await (await get(server.url, `/v1/sessions/${session.sessionId}`)).json();
assert.equal(detail.messages.length, 2, '会话详情应含用户与助手两条消息');
assert.equal(detail.messages[0].role, 'user');
assert.equal(detail.messages[1].role, 'assistant');
assert.ok(detail.messages[1].text.length > 0, '助手回复不应为空');
} finally {
await server.close();
}
});
test('同一幂等键重试不产生第二个 run', async () => {
const server = await startServer();
try {
const session = await createProjectSession(server.url);
const stream = await openStream(server.url, session.sessionId);
const body = { text: '幂等重试', idempotencyKey: 'idem-smoke-0002' };
const first = await (await post(server.url, `/v1/sessions/${session.sessionId}/messages`, body)).json();
const second = await post(server.url, `/v1/sessions/${session.sessionId}/messages`, body);
assert.equal(second.status, 202, '重试不应报冲突');
const secondBody = await second.json();
assert.equal(secondBody.runId, first.runId, '重试应返回同一个 runId');
assert.equal(secondBody.messageId, first.messageId);
await stream.waitFor(findIdleAfterCompleted, '回合结束');
stream.close();
const userEvents = stream.events.filter((event) => event.type === 'message.user');
assert.equal(userEvents.length, 1, '重试不应产生第二条 message.user');
} finally {
await server.close();
}
});
test('取消正在运行的回合', async () => {
// 注入一个开场慢 400ms 的假引擎,保证取消发生在「运行中」
const { createFakeRuntime } = await import('@aps/server/agent/fake-runtime');
const server = await startServer({ runtime: createFakeRuntime({ name: 'slow-fake', turns: [{ delayMs: 400, text: ['慢回合'] }] }) });
let stream;
try {
const session = await createProjectSession(server.url);
stream = await openStream(server.url, session.sessionId);
await post(server.url, `/v1/sessions/${session.sessionId}/messages`, { text: '取消我', idempotencyKey: 'idem-smoke-0003' });
const cancel = await post(server.url, `/v1/sessions/${session.sessionId}/cancel`, {});
assert.equal(cancel.status, 200);
await stream.waitFor(
(events) => events.some((event) => event.type === 'run.state' && event.data.state === 'cancelling') && findIdleAfterCompleted(events),
'取消完成',
);
stream.close();
const completed = stream?.events.find((event) => event.type === 'message.completed');
// 回合可能已经跑完(假引擎很快):只要取消是幂等的、状态回到 idle 就算通过
if (completed) assert.equal(completed.data.finishReason, 'cancelled');
} finally {
stream?.close();
await server.close();
}
});
test('断点续传:since 能回放已有事件', async () => {
const server = await startServer();
try {
const session = await createProjectSession(server.url);
const stream = await openStream(server.url, session.sessionId);
await post(server.url, `/v1/sessions/${session.sessionId}/messages`, { text: '回放用', idempotencyKey: 'idem-smoke-0004' });
await stream.waitFor(findIdleAfterCompleted, '回合结束');
stream.close();
const replay = await openStream(server.url, session.sessionId, '?since=1');
await replay.waitFor((events) => events.length > 0, '回放事件');
replay.close();
assert.ok(replay.events.every((event) => event.seq > 1), '回放只应包含 since 之后的事件');
assert.equal(replay.events[0].seq, 2);
} finally {
await server.close();
}
});
test('错误走 ErrorEnvelope;个人会话不允许带 projectId', async () => {
const server = await startServer();
try {
const missing = await get(server.url, '/v1/nope');
assert.equal(missing.status, 404);
const missingBody = await missing.json();
assert.equal(missingBody.error.code, 'not_found');
assert.equal(typeof missingBody.error.retryable, 'boolean');
const invalid = await post(server.url, '/v1/sessions', { scope: 'personal', projectId: 'p-default' });
assert.equal(invalid.status, 400);
const invalidBody = await invalid.json();
assert.equal(invalidBody.error.code, 'invalid_request');
// 执行记录在 A2 已实现:未知执行返回 404(不是 501)
const unknownExecution = await get(server.url, '/v1/executions/exec-x');
assert.equal(unknownExecution.status, 404);
const unknownExecutionBody = await unknownExecution.json();
assert.equal(unknownExecutionBody.error.code, 'not_found');
// 确认卡要到 A4 才有,这里仍是明确的未实现
const notImplemented = await post(server.url, '/v1/approvals/apr-x', { decision: 'approve', revision: 1, idempotencyKey: 'idem-approve-1' });
assert.equal(notImplemented.status, 501);
const notImplementedBody = await notImplemented.json();
assert.equal(notImplementedBody.error.code, 'not_implemented');
} finally {
await server.close();
}
});