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

249 lines
10 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.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() {
const { startServer: start } = await import('../server/dist/index.js');
return start({ host: '127.0.0.1', port: 0, version: 'test' });
}
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.match(detail.messages[1].text, /A1 占位引擎/);
} 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 () => {
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-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');
assert.equal(completed.data.finishReason, 'cancelled');
} finally {
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();
}
});