// deploy/m2_e2e.js —— M2 集成验收脚本(PM 使用,在远端运行) // 真实栈端到端:浏览器 → Vite dev(5180) → 真实 BFF(8090) → 真实上游(7920/8000/Neo4j) // 流程:新建会话 → 问答提问 → 流水线相位推进 → 答案揭示 → 来源 tab → 图谱 tab → 刷新恢复历史 // 运行:cd ~/5090/teamWorkspace/zh/PlantAI-rag-new && node deploy/m2_e2e.js // 注意:QA 上游 LLM 当前 402 降级,答案内容断言只验「有内容」不断言自然语言质量。 const { chromium } = require('playwright'); const BASE = 'http://127.0.0.1:5180'; const QUESTION = '水稻SPL基因家族的功能研究有哪些关键进展?'; const OUT_DIR = `${__dirname}/../artifacts/m2`; const results = []; function check(name, ok, extra = '') { results.push({ name, ok }); console.log(`${ok ? ' ✅' : ' ❌'} ${name}${extra ? ' —— ' + extra : ''}`); } async function waitFor(fn, timeoutMs, intervalMs = 800, label = '') { const start = Date.now(); let lastErr = null; while (Date.now() - start < timeoutMs) { try { const v = await fn(); if (v) return v; } catch (e) { lastErr = e; } await new Promise((r) => setTimeout(r, intervalMs)); } throw new Error(`等待超时(${timeoutMs}ms): ${label}${lastErr ? ' last=' + lastErr.message : ''}`); } async function stepStates(page) { return page.$$eval('.pipeline-card .p-step', (els) => els.map((el) => ({ name: el.querySelector('.p-name')?.textContent?.trim() || '', state: el.className.includes('done') ? 'done' : el.className.includes('running') ? 'running' : 'waiting', })), ); } (async () => { const fs = require('fs'); fs.mkdirSync(OUT_DIR, { recursive: true }); const browser = await chromium.launch(); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); try { // 1. 加载 await page.goto(BASE, { waitUntil: 'domcontentloaded' }); await waitFor(() => page.locator('.app-shell').count(), 15000, 500, 'AppShell 渲染'); check('页面加载:AppShell 渲染', true); await page.screenshot({ path: `${OUT_DIR}/01-loaded.png` }); // 2. 新建会话 await page.click('.new-task-btn'); await waitFor(() => page.locator('.composer textarea').count(), 15000, 500, '作曲器出现'); check('新建会话:作曲器出现', true); // 3. 提问(问答模式为默认) await page.fill('.composer textarea', QUESTION); await page.click('.send-btn'); await waitFor(() => page.locator('.pipeline-card').count(), 20000, 500, '流水线卡出现'); check('提问:流水线卡出现', true); // 4. 相位推进(真实 SSE,QA 上游约 40s 完成) const statesSeen = []; const t0 = Date.now(); let allDone = false; while (Date.now() - t0 < 240000) { const st = await stepStates(page); const key = JSON.stringify(st); if (!statesSeen.includes(key)) statesSeen.push(key); if (st.length && st.every((s) => s.state === 'done')) { allDone = true; break; } await new Promise((r) => setTimeout(r, 1500)); } check('相位推进:观察到多次状态变化', statesSeen.length >= 3, `${statesSeen.length} 个不同状态快照`); check('相位推进:全部完成', allDone, `${Math.round((Date.now() - t0) / 1000)}s`); await page.screenshot({ path: `${OUT_DIR}/02-pipeline.png` }); // 5. 答案揭示(complete 后揭示动画,最长 8s 加速) await waitFor(async () => { const txt = await page.locator('.msg.assistant .msg-body').last().innerText().catch(() => ''); return txt.length > 30; }, 60000, 1000, '助手答案出现'); const answerText = await page.locator('.msg.assistant .msg-body').last().innerText(); check('答案渲染:有内容', answerText.length > 30, `长度 ${answerText.length}`); check('答案渲染:含文献证据(PMID/DOI 或降级提示)', /PMID|doi|evidence|证据/i.test(answerText)); await page.screenshot({ path: `${OUT_DIR}/03-answer.png` }); // 6. 文献来源 tab(真实 sources) await page.click('.bench-tab:has-text("文献来源")'); await waitFor(async () => page.locator('.source-row').count(), 30000, 800, '来源条目出现'); const srcCount = await page.locator('.source-row').count(); check('文献来源:来源条目渲染', srcCount >= 1, `${srcCount} 条`); await page.screenshot({ path: `${OUT_DIR}/04-sources.png` }); // 7. 知识图谱 tab(答案图谱) await page.click('.bench-tab:has-text("知识图谱")'); await waitFor(async () => { const n = await page.locator('.bench-body svg, .bench-body .kg-list, .bench-body .empty-note').count(); return n > 0; }, 15000, 800, '图谱 tab 内容'); check('知识图谱:tab 有内容(图/列表/空态之一)', true); await page.screenshot({ path: `${OUT_DIR}/05-kg.png` }); // 8. 刷新恢复(真实 BFF SQLite 持久化) await page.reload({ waitUntil: 'domcontentloaded' }); await waitFor(() => page.locator('.session-item').count(), 15000, 500, '会话列表加载'); check('刷新:会话列表恢复(SQLite)', true); await page.click('.session-item:has-text("水稻SPL")'); await waitFor(async () => { const n = await page.locator('.msg.assistant').count(); return n > 0; }, 20000, 800, '历史消息恢复'); check('刷新:助手消息历史恢复', true); await page.screenshot({ path: `${OUT_DIR}/06-restored.png` }); // 9. 浏览器错误 const realErrors = errors.filter((e) => !e.includes('favicon')); check('无页面级 JS 错误', realErrors.length === 0, realErrors.slice(0, 3).join(' | ')); } catch (e) { check('流程异常中断', false, e.message); await page.screenshot({ path: `${OUT_DIR}/99-failure.png` }).catch(() => {}); } finally { await browser.close(); } const passed = results.filter((r) => r.ok).length; console.log(`\nM2 验收结果:${passed}/${results.length} 通过`); process.exit(passed === results.length ? 0 : 1); })();