- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化 - Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab - 四档响应式(桌面/笔记本/平板/手机)、深浅主题 - 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测 - 真实凭据已脱敏,运行时配置在 backend/.env(不入 git) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
139 lines
6.2 KiB
JavaScript
139 lines
6.2 KiB
JavaScript
// 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);
|
||
})();
|