242 lines
10 KiB
JavaScript
242 lines
10 KiB
JavaScript
/**
|
||
* 平台服务冒烟测试(任务 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');
|
||
|
||
const notImplemented = await get(server.url, '/v1/executions/exec-x');
|
||
assert.equal(notImplemented.status, 501);
|
||
const notImplementedBody = await notImplemented.json();
|
||
assert.equal(notImplementedBody.error.code, 'not_implemented');
|
||
} finally {
|
||
await server.close();
|
||
}
|
||
}); |