- 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
7.3 KiB
JavaScript
139 lines
7.3 KiB
JavaScript
// 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);
|
||
})();
|