// deploy/m5_acceptance.js —— M5 PM 最终验收脚本(在远端运行) // 与 M2/M3 验收互补:本轮跑「生产形态静态托管」+「加固后的 Vite」双链路, // 并在三档视口执行 PRD 验收脚本的核心交互(PM 人工走查的自动化部分)。 const { chromium } = require('playwright'); const results = []; function check(name, ok, extra = '') { results.push({ name, ok }); console.log(`${ok ? ' ✅' : ' ❌'} ${name}${extra ? ' —— ' + extra : ''}`); } async function waitFor(fn, ms, iv = 700, label = '') { const t0 = Date.now(); while (Date.now() - t0 < ms) { try { const v = await fn(); if (v) return v; } catch {} await new Promise((r) => setTimeout(r, iv)); } throw new Error(`等待超时(${ms}ms): ${label}`); } (async () => { const browser = await chromium.launch(); // ============ 0. 生产形态静态托管(BFF 8090 直接服务 dist)============ { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); // BFF 静态托管需 frontend/dist 存在且 BFF 重启——先探测是否已挂载 const prod = await page.goto('http://127.0.0.1:8090/', { waitUntil: 'domcontentloaded' }).then(() => true).catch(() => false); if (prod) { await waitFor(() => page.locator('.app-shell').count(), 15000, 500, '生产形态 AppShell'); check('生产形态:BFF 静态托管渲染 AppShell', true); const health = await page.evaluate(async () => (await fetch('/api/health')).status); check('生产形态:同源 /api/health 可达', health === 200); } else { // dist 未同步或 BFF 未带静态挂载重启——记失败但继续 check('生产形态:BFF 静态托管', false, '8090 根路径不可达(dist 未部署?)'); } check('生产形态无 JS 错误', errors.length === 0, errors.slice(0, 2).join('|')); await ctx.close(); } // ============ 1. Desktop 验收脚本(PRD 核心交互)============ { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' }); await waitFor(() => page.locator('.app-shell').count(), 15000, 500, 'AppShell'); // 会话列表来自真实 SQLite const sessCount = await page.locator('.session-item').count(); check('桌面:会话列表从真实 SQLite 加载', sessCount >= 1, `${sessCount} 条`); // 打开一个有 done 状态的会话验证历史恢复(含 markdown 渲染) const doneItem = page.locator('.session-item').filter({ has: page.locator('.status-dot.done') }).first(); if (await doneItem.count()) { await doneItem.click(); await waitFor(() => page.locator('.msg.assistant').count(), 15000, 500, '历史消息'); const hasMd = await page.locator('.msg.assistant .md-text strong, .msg.assistant .md-text li, .msg.assistant .md-text h1, .msg.assistant .md-text h2').count(); const hasPlain = await page.locator('.msg.assistant .md-text').count(); check('桌面:历史答案渲染(markdown 块存在)', hasPlain > 0, `md-text ${hasPlain} / 加粗列表 ${hasMd}`); // 相位卡恢复为 done const steps = await page.locator('.pipeline-card .p-step.done').count(); check('桌面:历史相位卡恢复 done', steps >= 4, `${steps}/5`); } // 引用 chip 高亮链(FR-CHAT-015 补全的验收) const chip = page.locator('.msg.assistant .inline-sources .src-chip').first(); if (await chip.count()) { await chip.click(); await waitFor(() => page.locator('.bench-tab.on').filter({ hasText: '文献来源' }).count(), 5000, 400, '来源 tab 切换'); await waitFor(() => page.locator('.source-row.hl-active').count(), 5000, 400, '来源高亮'); check('桌面:引用 chip → 来源 tab 高亮定位', true); } else { check('桌面:引用 chip → 来源 tab 高亮定位', false, '当前会话无引用 chip(可接受,需人工复核一次问答)'); } check('桌面验收无 JS 错误', errors.length === 0, errors.slice(0, 3).join('|')); await ctx.close(); } // ============ 2. 完整真实问答(桌面,加固后链路)============ { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' }); await waitFor(() => page.locator('.app-shell').count(), 15000, 500, 'AppShell'); await page.click('.new-task-btn'); await waitFor(() => page.locator('.composer textarea').count(), 15000, 500, '作曲器'); await page.fill('.composer textarea', '拟南芥HD-ZIP转录因子的生物学功能?'); await page.click('.send-btn'); // 等流结束(真实 QA 最长 92s,终态等待上限 360s 已加固) await waitFor(async () => { const ok = await page.evaluate(() => { const rootEl = document.querySelector('#app'); let n = rootEl; let app = null; while (n && !app) { if (n.__vue_app__) app = n.__vue_app__; n = n.__vueParentComponent?.parent || null; } if (!app) return false; const chat = app.config.globalProperties.$pinia._s.get('chat'); return chat && !chat.busy && chat.completedOnce; }); return ok; }, 360000, 1500, '真实问答流结束'); await waitFor(async () => { const n = await page.locator('.msg.assistant .md-text').count(); return n > 0; }, 30000, 800, '最终答案出现'); const answerLen = (await page.locator('.msg.assistant .md-text').last().textContent()).length; check('真实问答:五相位终态 + 答案渲染', answerLen > 20, `答案 ${answerLen} 字`); const allDone = (await page.locator('.pipeline-card .p-step.done').count()) >= 4; check('真实问答:相位卡全部 done', allDone); const srcChip = await page.locator('.msg.assistant .inline-sources .src-chip').count(); check('真实问答:内联引用 chip 出现', srcChip > 0, `${srcChip} 个`); check('真实问答无 JS 错误', errors.length === 0, errors.slice(0, 3).join('|')); await ctx.close(); } // ============ 3. 三档视口布局终检 ============ for (const [name, vp] of [['mobile', { width: 390, height: 844 }], ['tablet', { width: 768, height: 1024 }], ['desktop', { width: 1440, height: 900 }]]) { const ctx = await browser.newContext({ viewport: vp }); const page = await ctx.newPage(); await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' }); await waitFor(() => page.locator('.app-shell').count(), 15000, 500, `${name} AppShell`); const tier = await page.evaluate(() => document.querySelector('.app-shell').className); check(`${name}:档位 class 正确`, tier.includes(name === 'desktop' ? 'app-shell' : name === 'tablet' ? 'drawers-mode' : 'mobile'), tier.slice(0, 60)); await ctx.close(); } await browser.close(); const passed = results.filter((r) => r.ok).length; console.log(`\nM5 验收结果:${passed}/${results.length} 通过`); process.exit(passed === results.length ? 0 : 1); })();