import { test, expect } from 'playwright/test' // 冷启动首测含 vite 逐模块转换,可能远超默认 60s;放宽到 120s 防偶发超时(其余测试复用热服务很快)。 test.setTimeout(120_000) //! 「流式正文可见性」场景回放护栏。 //! //! 背景:这类 bug 反复回归(bcb0b82 修「流式正文被轮次折叠吞掉」,随后又迭「结果/过程分离」), //! 都出现在「含工具调用的多轮回复」这一场景,且都在实现/单测层测不到 —— 必须按真实事件序列 //! 回放整条链路(事件 → store → renderItems 分桶 → DOM)。故走 playwright 免真机路径 //! (src/mocks/tauri-shim.ts 垫片 + 事件注入),不动任何真实数据。 //! //! 目标契约(三条): //! (a) 多轮含工具调用回复完成后,有正文的轮次内容不得丢失 —— 过程轮叙述收进轨迹卡, //! 展开轨迹卡即可回看全文(不再「流式可见、收尾消失」); //! (b) 流式期间当前轮正文必须可见(气泡里能读到正在流入的文本); //! (c) 不得重复渲染同一段文本(轨迹卡展开态与最终气泡不重叠)。 //! //! 事件注入路径参考 tests/e2e/live-group.spec.ts:垫片把 listen 回调登记进 //! __TAURI_INTERNALS__.callbacks,Playwright 逐个投递 AiChatEvent 驱动 handleEvent。 //! 发送动作走真实 UI(sendMessage → doSend),故 streaming 态、事件监听注册等运行态与线上一致。 //! //! 会话上下文说明:浏览器垫片下 settings 无持久化,启动不自动选中会话(activeConversationId=null), //! 此时 AiTextDelta 会被 handleEvent 的「无活跃会话」守卫丢弃。故先注入一条带 conversation_id 的 //! AiConvStateChanged 建立活跃会话并触发历史(conv-mock-1)加载,再发消息。 const CID = 'conv-mock-1' const R1 = '过程轮叙述·护栏A1' // 过程轮(纯工具轮)的正文叙述 const FINAL = '最终答复·护栏Z9' // 末轮最终答复 /** 经垫片回调注入一条 AiChatEvent(投递给所有已注册的 handler) */ async function inject(page: import('playwright/test').Page, event: Record) { await page.evaluate((ev) => { const internals = (window as unknown as { __TAURI_INTERNALS__: { callbacks: Map void> } }).__TAURI_INTERNALS__ // payload 结构对齐 Tauri Event: { event, id, payload } for (const cb of internals.callbacks.values()) { cb({ event: 'ai-chat-event', id: 0, payload: ev }) } }, event) } /** 打开 AI 聊天页,建立活跃会话并等待其历史加载完成。 */ async function openReady(page: import('playwright/test').Page) { await page.goto('/#ai-home') await expect(page.locator('.ai-messages')).toBeVisible({ timeout: 20_000 }) await inject(page, { type: 'AiConvStateChanged', conversation_id: CID, conv_state: 'idle' }) // conv-mock-1 历史第 1 轮含工具调用 → 折叠为 1 张轨迹卡;出现即代表历史已加载 await expect(page.locator('.ai-trace-card').first()).toBeVisible({ timeout: 20_000 }) } /** 走真实发送路径发一条用户消息(同步 push 用户气泡并置 streaming)。 */ async function sendUser(page: import('playwright/test').Page, text: string) { const input = page.locator('.ai-input') await expect(input).toBeVisible() await input.fill(text) await input.press('Enter') await expect(page.locator('.ai-messages')).toContainText(text, { timeout: 5000 }) await inject(page, { type: 'AiConvStateChanged', conversation_id: CID, conv_state: 'generating' }) } /** 整轮收尾(AiCompleted 回填最终文本 + 复位 idle)。 */ async function completeReply(page: import('playwright/test').Page) { await inject(page, { type: 'AiCompleted', conversation_id: CID, total_tokens: 10, prompt_tokens: 6, completion_tokens: 4, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 6, reasoning_tokens: 0, }) await inject(page, { type: 'AiConvStateChanged', conversation_id: CID, conv_state: 'idle' }) } /** 统计某段文本在页面可见文本里出现的次数(重复渲染判据)。 */ async function countVisible(page: import('playwright/test').Page, text: string): Promise { return page.evaluate((t) => document.body.innerText.split(t).length - 1, text) } test('契约a+c:多轮含工具调用完成后,过程轮正文不丢(轨迹卡展开可见)且无重复', async ({ page }) => { await openReady(page) await sendUser(page, '请汇总进行中的任务(护栏场景)') // 轮 1:正文叙述 + 一次工具调用 await inject(page, { type: 'AiAgentRound', conversation_id: CID, round: 1 }) await inject(page, { type: 'AiTextDelta', conversation_id: CID, delta: `${R1}:先调取任务数据核对口径。` }) await inject(page, { type: 'AiToolCallStarted', conversation_id: CID, id: 'tc-guard-1', name: 'list_tasks', args: { status: 'in_progress' } }) await inject(page, { type: 'AiToolCallCompleted', conversation_id: CID, id: 'tc-guard-1', result: '[{"id":"t1","status":"in_progress"}]' }) // 轮 2:最终答复 await inject(page, { type: 'AiAgentRound', conversation_id: CID, round: 2 }) await inject(page, { type: 'AiTextDelta', conversation_id: CID, delta: `${FINAL}:进行中 3 项,均属 DevFlow 项目。` }) await completeReply(page) // 契约 a(前置):最终答复落在气泡里(结果/过程分离:气泡只放最终结果) await expect(page.locator('.ai-msg-bubble--answer', { hasText: FINAL })).toHaveCount(1, { timeout: 5000 }) // 契约 a(核心):过程轮叙述不得丢失 —— 收进轨迹卡,展开即可回看全文 + 工具卡 const traces = page.locator('.ai-trace-card') const n = await traces.count() expect(n, `轨迹卡数应 ≥2(历史 1 + 本轮 1),实际 ${n}`).toBeGreaterThanOrEqual(2) const ours = traces.last() await ours.locator('.ai-trace-head').click() await expect(ours.locator('.ai-trace-body')).toContainText(R1) await expect(ours.locator('.ai-trace-body .ai-tool-card')).toHaveCount(1) // 契约 c:无重复渲染 —— 每段文本在可见文本里各出现一次 expect(await countVisible(page, R1), `${R1} 重复渲染`).toBe(1) expect(await countVisible(page, FINAL), `${FINAL} 重复渲染`).toBe(1) }) // 契约 b(流式期间当前轮正文可见)。 // // 历史回归:模板