import { expect, test, type Page } from '@playwright/test'; import { Client } from 'pg'; /** * AI 候选处置端到端冒烟测试(真实后端 + 真 New-API LLM 生成,绝不 stub,反假绿)。 * * 现状(断层已闭合,正路改真生成):本 spec 正路全链不再 page.route 桩任何 AI 生成接口—— * 经真实浏览器 WorkspacePage→AIPanel 发起真后端 AI 任务→真 New-API LLM→SSE done→候选落库→ * CandidatePanel「采纳替换」/「放弃修改」→真 POST suggestion-merges 或 suggestions/{id}/reject。这清除了进度账 line176② * 「AI 生成链 e2e 一律 stub /ai/tasks」的最后一处 AI stub 假绿。 * * ── 为何此前桩、为何现在能删(ADR-020 已修,commit a16c596)────────────────────────── * 旧根因:真 runtime 产出的 runtimePermissionEnvelopeId 是字符串 `rpe-local-`(非数值), * 旧 authorization_snapshot_id 列为 BIGINT,投影时对非数值串解析失败 → 落库 authz=null → * AiSuggestionMergeProjectionFacade 见 authz==null 即判 unavailable → 采纳被拒 1041001001。 * 故旧正路只能桩 /ai/tasks + /stream 令 onDone 回传种子审齐候选 suggestion3(authz=9001 数值)。 * 修复(ADR-020 方案 A):authorization_snapshot_id 列 BIGINT→VARCHAR,直接承载字符串 envelope。 * 修后真生成候选 authz 现存 `rpe-local-*` 字符串 → 天然满足合并门禁,可被真采纳。 * 活体已证(2026-06-25 本机):对真生成 suggestion(id=20,authz=rpe-local-*) accept_as_is, * merge code=0、newRevision 自增(70→71),sourceAttribution.authorizationSnapshotId 回传该字符串。 * 故正路删桩改真生成,复用 ai-generation.spec 的真链路模式(work1/block1 + writing.continuation 槽位)。 * * ── 反假绿(正路)────────────────────────────────────────────────────────────────── * ① 发送指令后真打 POST /app-api/muse/ai/tasks(2xx + 业务码 code=0 + 回传 taskId,证真接口非 mock 截获); * ② SSE done 后 CandidatePanel「采纳替换」按钮可见(done→onCandidateGenerated 真贯通浏览器); * ③ DB 锚定:本轮真生成在 muse_ai_suggestion 落新行(id > 运行前基准,work1/block1,content 非空); * ④ 点击采纳真打 POST .../suggestion-merges → HTTP 200 + code=0 + data.newRevision 自增(相对采纳前实读 revision), * 且 sourceAttribution.parentSuggestionId == 本轮真生成 suggestionId(证采纳的是真候选、非种子)。 * 被采纳的候选与写入 Canonical 全真,无任何 stub。 * * ── 可重复(跑 2 次绿)──────────────────────────────────────────────────────────── * 每轮真生成产「新」suggestion(id 自增,恒新、不复用),故无须复位 suggestion; * 采纳的 expectedBlockRevision 由前端从后端实读(chapterDetail.blocks[0].revision),断言只比较 * newRevision > 采纳前实读 revision(动态,不硬编码),故 block revision 逐轮 +1 漂移不影响重复跑。 * work1 的 writing.continuation 槽位由 global-setup §15 每轮复位为 agent1/active,保证真生成可解析 Agent。 * * ── 前置 / 慢 + 异步 ────────────────────────────────────────────────────────────── * - 真实 muse-server 在 48080(V30 applied + ADR-020 authz=VARCHAR)+ New-API 在线 + muse_slice_live; * e2e 关 MSW(VITE_API_MOCK=false),前端经真实浏览器直连后端(vite 代理 /app-api→48080); * - work1(id=1)/chapter1(id=1)/block1(id=1) 已存在,writing.continuation 槽位 active(global-setup §15 复位); * - DB 凭据从 ~/.config/muse-repo/infra.env 环境变量读取(MUSE_POSTGRES_*,绝不入库); * - AI 生成依赖 New-API LLM(~7s) + dispatcher 延迟 + 轮询粒度,实测端到端约 20s,故 test timeout 放宽到 150s。 * * 后端 merge 主链路已由 P1rContentMergeSuggestionIT 在真实 PG 上证明(含正/负/幂等/状态/审门); * 「真实生成投影产出的候选可被采纳」在 ADR-020 后于活体打通,本 spec 即其真浏览器端到端见证。 */ /** 真实后端 mock 登录模式下的访问令牌(mockSecret 'test' + userId 1 → 'test1')。 */ const TOKEN = 'test1'; /** 真生成 + 采纳所用作品:work1 有 writing.continuation 槽位(global-setup §15 每轮复位 agent1/active)。 */ const WORK_ID = 1; /** work1/chapter1 下唯一 Block:AIPanel 自动以 blocks[0]=block1 为目标,采纳写入该 Canonical Block。 */ const BLOCK_ID = 1; /** 与 work1 KB 同主题,确保 grounding 续写命中(global-setup 已为 work1 种世界观/魔法体系 schema)。 */ const USER_INSTRUCTION = '继续描写星环大陆的魔法体系与元素对抗禁咒'; /** 整套用例放宽 timeout:AI 生成有外部 LLM + dispatcher 延迟 + 轮询粒度,远超默认 30s。 */ test.setTimeout(150_000); /** 创建直连 muse_slice_live 的 pg 客户端(凭据从 infra.env 环境变量,绝不入库)。 */ function createPgClient(): Client { return new Client({ host: process.env.MUSE_POSTGRES_HOST, port: Number(process.env.MUSE_POSTGRES_PORT ?? '5433'), database: process.env.MUSE_POSTGRES_DATABASE ?? 'muse_slice_live', user: process.env.MUSE_POSTGRES_USERNAME ?? 'root', password: process.env.MUSE_POSTGRES_PASSWORD, ssl: false, connectionTimeoutMillis: 10_000, }); } /** 读运行前 work1/block1 的 suggestion 最大 id,作为「本轮新生成」基准(workers=1 串行,无并发污染)。 */ async function readMaxSuggestionId(): Promise { const c = createPgClient(); await c.connect(); try { const r = await c.query( `SELECT COALESCE(MAX(id), 0) AS max_id FROM muse_ai_suggestion WHERE tenant_id=1 AND work_id=$1 AND block_id=$2 AND deleted=false`, [WORK_ID, BLOCK_ID] ); return Number(r.rows[0].max_id); } finally { await c.end(); } } /** 读 id 大于基准的最新真生成 suggestion(DB 核验本轮真落库;返回其 id 供采纳后比对 parentSuggestionId)。 */ async function readLatestSuggestionAbove( baselineId: number ): Promise<{ id: number; status: string; authz: string | null; content: string; decisionArchiveId: number | null } | null> { const c = createPgClient(); await c.connect(); try { const r = await c.query( `SELECT id, status, authorization_snapshot_id AS authz, content_snapshot->>'content' AS content, decision_archive_id FROM muse_ai_suggestion WHERE tenant_id=1 AND work_id=$1 AND block_id=$2 AND deleted=false AND id > $3 ORDER BY id DESC LIMIT 1`, [WORK_ID, BLOCK_ID, baselineId] ); if (r.rowCount === 0) return null; const row = r.rows[0]; return { id: Number(row.id), status: String(row.status), authz: row.authz == null ? null : String(row.authz), content: row.content == null ? '' : String(row.content), decisionArchiveId: row.decision_archive_id == null ? null : Number(row.decision_archive_id), }; } finally { await c.end(); } } /** 读指定 suggestion 的处置状态与 decision archive,证明采纳后候选离开 pending 队列。 */ async function readSuggestionDecision( suggestionId: number ): Promise<{ status: string; decisionArchiveId: number | null; decisionCount: number }> { const c = createPgClient(); await c.connect(); try { const r = await c.query( `SELECT s.status, s.decision_archive_id, COUNT(d.id)::int AS decision_count FROM muse_ai_suggestion s LEFT JOIN muse_ai_suggestion_decision d ON d.tenant_id=s.tenant_id AND d.suggestion_id=s.id AND d.deleted=false WHERE s.tenant_id=1 AND s.id=$1 AND s.deleted=false GROUP BY s.status, s.decision_archive_id`, [suggestionId] ); if (r.rowCount === 0) throw new Error(`suggestion id=${suggestionId} 不存在`); const row = r.rows[0]; return { status: String(row.status), decisionArchiveId: row.decision_archive_id == null ? null : Number(row.decision_archive_id), decisionCount: Number(row.decision_count), }; } finally { await c.end(); } } /** 实读目标 Block 当前 revision(与前端 blockRevision 同口径:取 muse_content_block.revision)。 */ async function readBlockRevision(): Promise { const c = createPgClient(); await c.connect(); try { const r = await c.query( `SELECT revision FROM muse_content_block WHERE tenant_id=1 AND work_id=$1 AND id=$2`, [WORK_ID, BLOCK_ID] ); if (r.rowCount === 0) throw new Error(`block id=${BLOCK_ID} 不存在(基础种子缺失)`); return Number(r.rows[0].revision); } finally { await c.end(); } } /** 实读目标 Block 当前 Canonical 正文(muse_content_block.content_text),用于「所见即所写」核验。 */ async function readBlockContentText(): Promise { const c = createPgClient(); await c.connect(); try { const r = await c.query( `SELECT content_text FROM muse_content_block WHERE tenant_id=1 AND work_id=$1 AND id=$2`, [WORK_ID, BLOCK_ID] ); if (r.rowCount === 0) throw new Error(`block id=${BLOCK_ID} 不存在(基础种子缺失)`); return r.rows[0].content_text == null ? '' : String(r.rows[0].content_text); } finally { await c.end(); } } /** 读 work1/block1 最新一条 pending 候选 id(供负路乐观锁用例;版本冲突在 authz 校验前先判,故任意 pending 即可)。 */ async function readLatestPendingSuggestionId(): Promise { const c = createPgClient(); await c.connect(); try { const r = await c.query( `SELECT id FROM muse_ai_suggestion WHERE tenant_id=1 AND work_id=$1 AND block_id=$2 AND deleted=false AND status='pending' ORDER BY id DESC LIMIT 1`, [WORK_ID, BLOCK_ID] ); return r.rowCount === 0 ? null : Number(r.rows[0].id); } finally { await c.end(); } } /** * 在页面加载前注入登录令牌 + 租户到 localStorage。 * WHY:studio 的 HTTP 客户端从 localStorage 读取 accessToken/tenantId 注入 Bearer/tenant-id;e2e 必须在首屏脚本 * 执行前写入,故用 addInitScript 而非加载后再 setItem(后者会晚于首批请求)。 */ async function seedToken(page: Page): Promise { await page.addInitScript((t) => { window.localStorage.setItem('accessToken', t); window.localStorage.setItem('tenantId', '1'); }, TOKEN); } test.describe('AI 候选处置闭环(真实后端 + 真生成,不 stub)', () => { test('真生成候选 → 采纳 → newRevision 自增(全链真后端,无 AI stub)', async ({ page }) => { // 0. 记录运行前 work1/block1 的 suggestion 基准 id:本轮 UI 真生成应产出 id 更大的新行(DB 反假绿锚点)。 const baselineSuggestionId = await readMaxSuggestionId(); await seedToken(page); // 导航到真实创作工作台路由(见 src/app/routes/index.tsx 的 workspace/:workId); // WorkspacePage 自动激活 chapter1 + blocks[0]=block1,AIPanel 拿到 blockId=1。 await page.goto(`/workspace/${WORK_ID}`); // 1. 等工作台就绪:AIPanel 发送按钮出现。 await expect(page.getByRole('button', { name: /发送指令/ })).toBeVisible({ timeout: 30_000 }); // 2. ① 填真实指令并捕获真 POST /ai/tasks(反假绿:真打真接口,非 page.route 截获)。 const promptBox = page.getByPlaceholder(/在此处输入创作描述/); await promptBox.fill(USER_INSTRUCTION); const taskResponsePromise = page.waitForResponse( (resp) => /\/app-api\/muse\/ai\/tasks$/.test(resp.url()) && resp.request().method() === 'POST', { timeout: 30_000 } ); await page.getByRole('button', { name: /发送指令/ }).click(); const taskResponse = await taskResponsePromise; // 异步受理:真后端对 POST /ai/tasks 返回 2xx(任务入队,稍后经 SSE done 推 suggestion)。只断言 2xx, // 业务真伪以 code=0 为准(不强求 200,避免把正确的 202 误判为红)。 expect(taskResponse.status(), 'POST /ai/tasks HTTP 状态应为 2xx').toBeGreaterThanOrEqual(200); expect(taskResponse.status(), 'POST /ai/tasks HTTP 状态应为 2xx').toBeLessThan(300); const taskBody = await taskResponse.json(); expect(taskBody.code, `创建 AI 任务响应: ${JSON.stringify(taskBody)}`).toBe(0); expect(taskBody?.data?.taskId, 'AI 任务应回传 taskId').toBeTruthy(); // 3. ② 等真 SSE done → onCandidateGenerated → CandidatePanel「采纳替换」按钮可见,证候选已就绪。 // 放宽到 120s:覆盖 New-API cold/慢 + dispatcher 延迟 + 5s 轮询粒度。绝不因 flaky 改 stub 绕过。 const acceptButton = page.getByRole('button', { name: /采纳替换/ }); await expect(acceptButton).toBeVisible({ timeout: 120_000 }); await expect(acceptButton).toBeEnabled({ timeout: 30_000 }); // 4. ③ DB 核验:本轮真生成应在 muse_ai_suggestion 落新行(id > 基准),work1/block1、内容非空、authz 非空。 // authz 非空是 ADR-020 修复的直接见证(修前真生成候选 authz=null → 不可采纳)。 const dbRow = await readLatestSuggestionAbove(baselineSuggestionId); expect(dbRow, `应在 DB 找到本轮真生成 suggestion(id > ${baselineSuggestionId})`).not.toBeNull(); expect(dbRow!.content.length, 'DB suggestion 内容应非空(真 LLM 生成)').toBeGreaterThan(0); expect(dbRow!.authz, 'ADR-020 后真生成候选 authz 应为非空字符串 envelope').toBeTruthy(); const generatedSuggestionId = dbRow!.id; // 5. 采纳前实读 Block revision(前端 expectedBlockRevision 同口径:取 block.revision)。 // 动态读而非硬编码:block revision 逐轮采纳 +1 漂移,跑 2 次须可重复。 const revisionBeforeAccept = await readBlockRevision(); // 6. ④ 点击采纳,捕获真实 suggestion-merges 写入响应(直打真后端 merge,反假绿核心)。 const [mergeResponse] = await Promise.all([ page.waitForResponse( (resp) => /\/suggestion-merges$/.test(resp.url()) && resp.request().method() === 'POST', { timeout: 30_000 } ), acceptButton.click(), ]); // (a) 写入命令 HTTP 200。 expect(mergeResponse.status()).toBe(200); const mergeBody = await mergeResponse.json(); // (b) 业务码 code=0(真采纳成功,非被门禁/乐观锁拒)。 expect(mergeBody.code, `采纳响应: ${JSON.stringify(mergeBody).slice(0, 200)}`).toBe(0); // (c) newRevision 自增(相对采纳前实读 revision;动态比较,不硬编码 revision)。 const newRevision = mergeBody?.data?.newRevision; expect(typeof newRevision).toBe('number'); expect(newRevision).toBeGreaterThan(revisionBeforeAccept); // (d) 采纳的确是本轮真生成候选(parentSuggestionId == 真生成 suggestionId),证非种子/非 mock。 expect(String(mergeBody?.data?.sourceAttribution?.lineage?.parentSuggestionId)).toBe( String(generatedSuggestionId) ); // (d2) 所见即所写(S7c 裁决②):前端采纳恒以 merge_after_edit + modify_then_merge 上送用户审定的完整正文, // 故 acceptMode 现为 merge_after_edit(此前为 accept_as_is——后端会改用自己存的摘要写正文,已被本步修掉)。 expect(mergeBody?.data?.sourceAttribution?.lineage?.acceptMode).toBe('merge_after_edit'); // (d3) 采纳请求体核验:真打的 suggestion-merges 携带 merge_after_edit + 非空 finalContent(客户端回传正文)。 const mergeReqBody = JSON.parse(mergeResponse.request().postData() ?? '{}'); expect(mergeReqBody.mergeMode, '采纳恒走 merge_after_edit').toBe('merge_after_edit'); expect(mergeReqBody.decisionType, '与 mergeMode 成对,恒 modify_then_merge').toBe('modify_then_merge'); expect(typeof mergeReqBody.finalContent, 'finalContent 必为客户端回传的完整正文字符串').toBe('string'); expect(String(mergeReqBody.finalContent).length, 'finalContent 非空').toBeGreaterThan(0); // (d4) 所见即所写 Canonical 证明:写入 muse_content_block.content_text 的正文严格等于客户端回传的 finalContent, // 证 Canonical 由用户审定的回传正文写入(而非后端存的 60/80 字脱敏摘要)。 const canonicalContent = await readBlockContentText(); expect(canonicalContent, 'Canonical 正文应等于客户端回传的 finalContent(所见即所写)').toBe( String(mergeReqBody.finalContent) ); // (e) AI owner 必须把被采纳候选标为 accepted 并写 decision archive;否则候选仍 pending,后续可被重复处置。 const acceptedDecision = await readSuggestionDecision(generatedSuggestionId); expect(acceptedDecision.status).toBe('accepted'); expect(acceptedDecision.decisionArchiveId).toBeTruthy(); expect(acceptedDecision.decisionCount).toBeGreaterThanOrEqual(1); // 留痕:打印真 suggestionId 与 revision 跃迁,作为反假绿证据(运行日志可见)。 console.log( `[accept-suggestion] 真生成 suggestionId=${generatedSuggestionId} authz=${dbRow!.authz} ` + `revision ${revisionBeforeAccept}→${newRevision}` ); // (e) 刷新后断言工作台仍渲染该作品(对比面板已关闭,回到 AI 面板)。 await page.reload(); await expect(page.getByRole('button', { name: /发送指令/ })).toBeVisible({ timeout: 30_000 }); }); test('错误的 expectedBlockRevision 导致采纳因乐观锁冲突被拒(HTTP 200 + 业务错误码)', async ({ page }) => { await seedToken(page); // 取 work1/block1 一条 pending 候选作 suggestionId:版本冲突在 authz 校验前先判,故任意 pending 即可 //(活体已证 null-authz 候选 + stale revision 同样先返回 1041000002,与候选 authz 无关)。 const pendingId = await readLatestPendingSuggestionId(); expect(pendingId, 'work1/block1 应至少有一条 pending 候选供负路用例').not.toBeNull(); // 实读当前 revision,再以「明显过期」revision 触发乐观锁冲突(动态读,避免硬编码漂移误红)。 const liveRevision = await readBlockRevision(); const wrongRevision = liveRevision + 999; // 用 page.request 直接打接口,绕开 UI,专注验证后端乐观锁负路径;Bearer/tenant 显式带上。 // 注意:Yudao 全局异常处理把 ServiceException 包成 HTTP 200 + CommonResult{code≠0,msg},并非 REST 409 //(P1rContentMergeSuggestionIT 已证实该口径)。 const response = await page.request.post( `/app-api/muse/works/${WORK_ID}/blocks/${BLOCK_ID}/suggestion-merges`, { headers: { 'X-API-Version': '1', Authorization: `Bearer ${TOKEN}`, // 后端按 tenant-id 头解析租户;page.request 绕开 client.ts,需手动带上(否则报租户未传递)。 'tenant-id': '1', 'Content-Type': 'application/json', }, data: { commandId: crypto.randomUUID(), suggestionId: String(pendingId), expectedBlockRevision: wrongRevision, decisionType: 'accept', mergeMode: 'accept_as_is', sourceSnapshot: { sourceType: 'ai_suggestion' }, auditReason: 'e2e negative path: stale revision', }, } ); expect(response.status()).toBe(200); const body = await response.json(); expect(body.code).not.toBe(0); expect(String(body.msg)).toContain('版本'); }); test('真生成候选 → 放弃修改 → 后端 reject 归档(全链真后端,无 AI stub)', async ({ page }) => { const baselineSuggestionId = await readMaxSuggestionId(); await seedToken(page); await page.goto(`/workspace/${WORK_ID}`); await expect(page.getByRole('button', { name: /发送指令/ })).toBeVisible({ timeout: 30_000 }); const promptBox = page.getByPlaceholder(/在此处输入创作描述/); await promptBox.fill(`${USER_INSTRUCTION},本轮用于拒绝候选闭环验收`); const taskResponsePromise = page.waitForResponse( (resp) => /\/app-api\/muse\/ai\/tasks$/.test(resp.url()) && resp.request().method() === 'POST', { timeout: 30_000 } ); await page.getByRole('button', { name: /发送指令/ }).click(); const taskResponse = await taskResponsePromise; expect(taskResponse.status(), 'POST /ai/tasks HTTP 状态应为 2xx').toBeGreaterThanOrEqual(200); expect(taskResponse.status(), 'POST /ai/tasks HTTP 状态应为 2xx').toBeLessThan(300); const taskBody = await taskResponse.json(); expect(taskBody.code, `创建 AI 任务响应: ${JSON.stringify(taskBody)}`).toBe(0); expect(taskBody?.data?.taskId, 'AI 任务应回传 taskId').toBeTruthy(); const rejectButton = page.getByRole('button', { name: /放弃修改/ }); await expect(rejectButton).toBeVisible({ timeout: 120_000 }); await expect(rejectButton).toBeEnabled({ timeout: 30_000 }); const dbRow = await readLatestSuggestionAbove(baselineSuggestionId); expect(dbRow, `应在 DB 找到本轮真生成 suggestion(id > ${baselineSuggestionId})`).not.toBeNull(); expect(dbRow!.content.length, 'DB suggestion 内容应非空(真 LLM 生成)').toBeGreaterThan(0); expect(dbRow!.authz, '真生成候选 authz 应为非空字符串 envelope').toBeTruthy(); const generatedSuggestionId = dbRow!.id; const [rejectResponse] = await Promise.all([ page.waitForResponse( (resp) => new RegExp(`/app-api/muse/suggestions/${generatedSuggestionId}/reject$`).test(resp.url()) && resp.request().method() === 'POST', { timeout: 30_000 } ), rejectButton.click(), ]); expect(rejectResponse.status()).toBe(200); const rejectBody = await rejectResponse.json(); expect(rejectBody.code, `拒绝候选响应: ${JSON.stringify(rejectBody).slice(0, 200)}`).toBe(0); const rejectedDecision = await readSuggestionDecision(generatedSuggestionId); expect(rejectedDecision.status).toBe('rejected'); expect(rejectedDecision.decisionArchiveId).toBeTruthy(); expect(rejectedDecision.decisionCount).toBeGreaterThanOrEqual(1); await expect(page.getByRole('button', { name: /采纳替换/ })).toBeHidden({ timeout: 15_000 }); console.log(`[reject-suggestion] 真生成 suggestionId=${generatedSuggestionId} 已 reject 并写 decision archive`); }); });