/** * 平台服务冒烟测试(任务 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(); } });