PlantAI-rag-new 首次提交:文献知识工作台

- 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>
This commit is contained in:
jerry
2026-08-25 20:47:52 +08:00
co-authored by Claude Fable 5
commit 1e94de7464
171 changed files with 26291 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
// bench.spec.js —— T 归属。L5 主线 7 图谱(TC-E2E-07-*)+ 主线 8 抽取(TC-E2E-08-*)。
// 图谱:答案图谱(真实 BFF 返回 243 节点 → 桌面/平板超 50 降级列表视图;移动端上限 30)、
// 实体搜索(真实 Neo4j name_norm)、邻域展开、节点详情。
// 抽取:真实上游 8000 任务列表/详情/日志;上传在 mock 拦截下模拟成功。
import { test, expect } from '@playwright/test';
import { tier, selectBenchTab, openBench, benchScope, openSessionPanel, closeSessionPanel, cleanupSessionsByTitle } from './helpers.js';
test.describe('E2E-07 知识图谱', () => {
let pageRef = null;
test.afterEach(async () => {
if (pageRef) await cleanupSessionsByTitle(pageRef, 'R1图谱');
});
test('实体搜索 → 邻域渲染 → 节点详情 → 答案图谱降级/渲染', async ({ page }) => {
test.setTimeout(180_000);
const t = tier(page);
pageRef = page;
await cleanupSessionsByTitle(page, 'R1图谱');
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 打开知识图谱 tab
await selectBenchTab(page, '知识图谱');
const sc = benchScope(page);
const kgPane = sc.locator('.kg-pane');
await expect(kgPane).toBeVisible({ timeout: 10_000 });
// 1. 空态(无问答时提示引导搜索)
await expect(kgPane.locator('.empty-note')).toContainText('图谱', { timeout: 10_000 });
// 2. 实体搜索(真实 Neo4j;精确/前缀匹配,<1s 内返回——超时上限放宽到 10s)
const searchInput = kgPane.locator('.search-box');
await searchInput.fill('OsSPL14');
await kgPane.locator('.search-btn').click();
await expect(kgPane.locator('.result-item').first()).toBeVisible({ timeout: 10_000 });
await expect(kgPane.locator('.result-item').first()).toContainText('OsSPL14');
// 3. 选中结果 → 邻域渲染(一跳)。真实数据 OsSPL14 一跳返回 51 节点,
// 按 FR-KG-001 节点数 >50 产品降级为列表视图——先等待两种终态之一出现,再按形态断言
const firstResult = kgPane.locator('.result-item').first();
await firstResult.click();
await expect(kgPane.locator('.degrade-note, .graph-svg').first()).toBeVisible({ timeout: 20_000 });
const degraded = (await kgPane.locator('.degrade-note').count()) > 0;
if (degraded) {
await expect(kgPane.locator('.node-list')).toBeVisible();
await expect(kgPane.locator('.node-list-item').first()).toBeVisible();
} else {
await expect(kgPane.locator('.node-g').first()).toBeVisible();
}
await expect(kgPane.locator('.nb-center')).toContainText(/OsSPL14/i, { timeout: 20_000 });
// 4. 节点详情:图模式点 node-g;列表模式点 node-list-item(均打开侧滑面板)
if (degraded) {
await kgPane.locator('.node-list-item').first().click();
} else {
await kgPane.locator('.node-g').first().click();
}
await expect(page.locator('.kg-entity-detail')).toBeVisible({ timeout: 10_000 });
await expect(page.locator('.kg-entity-detail .close-btn')).toBeVisible();
await page.locator('.kg-entity-detail .close-btn').click();
await expect(page.locator('.kg-entity-detail')).toHaveCount(0);
// 5. 邻域深度切换(深度 2 较慢,断言慢提示存在即可)
if (await kgPane.locator('.depth-row').count()) {
await kgPane.locator('.depth-row .chip').nth(1).click();
await expect(kgPane.locator('.nb-slow, .depth-hint').first()).toBeAttached({ timeout: 60_000 });
}
// 6. 答案图谱路径:经历史会话(已有消息)验证 graph 块渲染路径。
// 先关闭工作台(手机覆盖层/平板抽屉 scrim 会拦截后续顶栏点击),再打开会话面板
if (tier(page) === 'mobile') {
await page.locator('.bench-overlay .bench-close-btn').click();
await expect(page.locator('.bench-overlay')).toHaveCount(0);
} else if (tier(page) === 'tablet') {
await page.keyboard.press('Escape');
await expect(page.locator('.drawer.right.open')).toHaveCount(0);
}
await openSessionPanel(page);
const item = page.locator('.session-item').first();
await item.click();
await expect(page.locator('.msg.assistant, .empty-state')).toBeAttached({ timeout: 10_000 });
// 关闭会话抽屉(scrim 会拦截工作台按钮点击),再打开知识图谱 tab
await closeSessionPanel(page);
await selectBenchTab(page, '知识图谱');
// 历史消息 graph 块渲染为画布或降级列表(节点 >50 → 降级提示 + 列表视图)
await expect(
page.locator('.kg-pane .graph-svg, .kg-pane .node-list, .kg-pane .empty-note').first()
).toBeVisible({ timeout: 15_000 });
if (await page.locator('.kg-pane .degrade-note').count()) {
await expect(page.locator('.kg-pane .node-list')).toBeVisible();
}
});
});
test.describe('E2E-08 提取任务', () => {
test('任务列表 → 详情展开 → 运行日志 → 上传(mock 成功)', async ({ page }) => {
test.setTimeout(120_000);
await cleanupSessionsByTitle(page, 'R1抽取');
// 上传接口 mock:成功响应(避免真实 MinerU 环境问题影响 UI 断言)
await page.route('**/api/extraction/upload', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, job_id: 'mockjob000001', status_url: '/api/extraction/jobs/mockjob000001', message: '已提交抽取任务' }),
})
);
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
await selectBenchTab(page, '提取任务');
const sc = benchScope(page);
const extPane = sc.locator('.extraction-pane');
// 1. 任务列表(真实上游 49 个历史任务)
await expect(extPane.locator('.prog-item').first()).toBeVisible({ timeout: 15_000 });
const pctText = await extPane.locator('.pi-pct').first().textContent();
expect(pctText).toMatch(/%|失败|错误/); // 百分比或 failed 错误态
// 2. 详情展开(点击头部)
const firstJob = extPane.locator('.prog-item').first();
await firstJob.locator('.pi-head').click();
await expect(extPane.locator('.job-detail').first()).toBeVisible({ timeout: 15_000 });
await expect(extPane.locator('.job-detail .status-row')).toBeVisible();
// 3. 运行日志(mono 展示;真实上游为空属正常——DISCOVERY.md §3.4)
await extPane.locator('.log-toggle').click();
await expect(extPane.locator('.run-log')).toBeVisible({ timeout: 10_000 });
// 4. 上传(mock 成功):选择文件 → 成功 ✓ 提示 + 刷新列表
const fileInput = extPane.locator('.hidden-input');
await fileInput.setInputFiles({
name: 'r1-sample.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('%PDF-1.4 mock'),
});
await expect(extPane.locator('.upload-msg.ok')).toBeVisible({ timeout: 10_000 });
// 5. 非 PDF 被拒(前端拦截,不发请求)
await fileInput.setInputFiles({
name: 'not-a-pdf.txt',
mimeType: 'text/plain',
buffer: Buffer.from('hello'),
});
await expect(extPane.locator('.upload-msg.err')).toContainText('仅支持 PDF', { timeout: 10_000 });
});
});
+190
View File
@@ -0,0 +1,190 @@
// helpers.js —— T 归属。L5 用例公共工具:档位判定、会话面板/工作台入口、提问与等待、清理。
// 注意:抽屉在 DOM 中常驻(Teleport),关闭态仅 translateX 移出视口——Playwright 的 :visible
// 仍会命中它,因此所有交互选择器必须按容器精确限定(aside-host / .drawer.left / .drawer.right / .bench-overlay)。
import { expect } from '@playwright/test';
export function tier(page) {
const w = page.viewportSize().width;
if (w >= 1280) return 'desktop';
if (w >= 1024) return 'laptop';
if (w >= 768) return 'tablet';
return 'mobile';
}
/** 会话面板精确定位(桌面=左栏;平板/手机=左抽屉内) */
export function sessionPanel(page) {
return page.locator('.aside-host .session-panel, .drawer.left .session-panel');
}
/** 工作台面板精确定位(桌面=右栏;平板=右抽屉内;手机=覆盖层内) */
export function benchPanel(page) {
return page.locator('.bench-wrap .bench-panel, .drawer.right .bench-panel, .bench-overlay .bench-panel');
}
/** 当前档位下「唯一可交互」的工作台容器(按容器形态精确限定,避免命中隐藏副本) */
export function benchScope(page) {
const t = tier(page);
if (t === 'desktop' || t === 'laptop') return page.locator('.bench-wrap');
if (t === 'tablet') return page.locator('.drawer.right');
return page.locator('.bench-overlay');
}
/** 打开会话面板(平板/手机点汉堡;桌面已常驻) */
export async function openSessionPanel(page) {
const t = tier(page);
if (t === 'tablet' || t === 'mobile') {
if (!(await page.locator('.drawer.left.open').count())) {
await page.locator('.hamburger-btn').click();
await expect(page.locator('.drawer.left.open')).toBeVisible();
}
}
}
/** 关闭会话面板(抽屉形态:Esc 优先;scrim 点击取右侧未被抽屉覆盖的坐标) */
export async function closeSessionPanel(page) {
const t = tier(page);
if (t === 'tablet' || t === 'mobile') {
if (await page.locator('.drawer.left.open').count()) {
// 抽屉(左 260px)覆盖 scrim 中心,直接点 scrim 元素中心会因被遮挡而挂起——
// 用 Esc(Drawer 监听 document keydown),失败再点 scrim 右侧安全坐标
await page.keyboard.press('Escape');
if (await page.locator('.drawer.left.open').count()) {
const vp = page.viewportSize();
await page.mouse.click(vp.width - 15, Math.floor(vp.height / 2));
}
await expect(page.locator('.drawer.left.open')).toHaveCount(0);
}
}
}
/** 打开工作台(桌面=右栏已可见;平板=右抽屉;手机=全屏覆盖层) */
export async function openBench(page) {
const t = tier(page);
if (t === 'tablet') {
if (!(await page.locator('.drawer.right.open').count())) {
await page.locator('.bench-btn').click();
await expect(page.locator('.drawer.right.open')).toBeVisible();
}
} else if (t === 'mobile') {
if (!(await page.locator('.bench-overlay').count())) {
await page.locator('.bench-btn').click();
await expect(page.locator('.bench-overlay')).toBeVisible();
}
}
}
/** 工作台 tab 按钮(按容器精确限定) */
export function benchTab(page, label) {
const t = tier(page);
const scope =
t === 'desktop' ? '.bench-wrap' : t === 'tablet' ? '.drawer.right' : '.bench-overlay .bench-overlay-tabs';
return page.locator(`${scope} .bench-tab`).filter({ hasText: label }).first();
}
/** 点击工作台某个 tab */
export async function selectBenchTab(page, label) {
await openBench(page);
await benchTab(page, label).click();
}
/** 新建会话(返回后会话面板保持打开;调用方按需 closeSessionPanel) */
export async function newSession(page) {
await openSessionPanel(page);
const before = await page.locator('.session-item').count();
await sessionPanel(page).locator('.new-task-btn').click();
await expect(page.locator('.session-item')).toHaveCount(before + 1, { timeout: 10_000 });
await expect(page.locator('.empty-state')).toBeVisible();
}
/** 模式 key → 中文 label(labels.MODES:ask=问答 plan=方案 search=检索) */
const MODE_LABELS = { ask: '问答', plan: '方案', search: '检索' };
/** 在作曲器提问(可选切换模式 pill;mode 为 key,按中文 label 匹配) */
export async function submitQuestion(page, question, mode = 'ask') {
const composer = page.locator('.composer');
if (mode !== 'ask') {
const label = MODE_LABELS[mode] || mode;
await composer.locator('.mode-pill').filter({ hasText: new RegExp(`^${label}$`) }).click();
await expect(composer.locator('.mode-pill.on')).toContainText(label);
}
await composer.locator('textarea').fill(question);
await composer.locator('.send-btn').click();
}
/**
* 等待本轮问答终态(pipeline 徽章 done/error),期间观测相位 running 推进。
* 返回 { done, sawRunning };揭示动画未完成时点击「跳过」加速。
*/
export async function waitForAnswerEnd(page, { timeout = 240_000 } = {}) {
let sawRunning = false;
const badge = page.locator('.msg.assistant .pipeline-card .badge').last();
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (await page.locator('.p-step.running').count()) sawRunning = true;
const cls = await badge.getAttribute('class').catch(() => '');
if (cls && cls.includes('done')) break;
if (cls && cls.includes('error')) break;
await page.waitForTimeout(800);
}
const cls = (await badge.getAttribute('class').catch(() => '')) || '';
// 揭示动画:出现跳过按钮则点击,加速拿到最终 text 块
const skip = page.locator('.msg.assistant .skip-btn').last();
if (await skip.count()) await skip.click().catch(() => {});
return { done: cls.includes('done'), sawRunning };
}
/** 清理本轮测试创建的会话(按标题前缀匹配,经 API 删除,避免污染共享库) */
export async function cleanupSessionsByTitle(page, prefix) {
try {
const list = await page.request.get('/api/sessions');
if (!list.ok()) return;
const items = await list.json();
for (const s of items) {
if ((s.title || '').startsWith(prefix)) {
await page.request.delete(`/api/sessions/${s.id}`);
}
}
} catch {
/* 清理失败不影响测试结论 */
}
}
/**
* 快照当前全部会话 ID(测试开始时调用)。
* 按 ID 精确清理可避免标题前缀误伤他人会话(共享 SQLite,PM 的集成会话同名共存)。
*/
export async function sessionIds(page) {
try {
const list = await page.request.get('/api/sessions');
if (!list.ok()) return null;
const items = await list.json();
return new Set(items.map((s) => s.id));
} catch {
return null;
}
}
/** 删除本轮新建的会话(ID 不在 preIds 快照内的) */
export async function cleanupNewSessions(page, preIds) {
if (!preIds) return;
try {
const list = await page.request.get('/api/sessions');
if (!list.ok()) return;
const items = await list.json();
for (const s of items) {
if (!preIds.has(s.id)) {
await page.request.delete(`/api/sessions/${s.id}`);
}
}
} catch {
/* 清理失败不影响测试结论 */
}
}
/** 页面控制台错误收集(失败证据用) */
export function trackConsoleErrors(page, bucket) {
page.on('console', (msg) => {
if (msg.type() === 'error') bucket.push(msg.text());
});
page.on('pageerror', (err) => bucket.push(String(err)));
}
+138
View File
@@ -0,0 +1,138 @@
// main-flow.spec.js —— T 归属。L5 主线 1:主流程(TC-E2E-01-*)+ 主线 2:检索模式(TC-E2E-02-*)。
// 真实 BFF 上游:QA 约 37s/次(DISCOVERY.md);LLM 402 → answer 为 fallback 证据列表,
// 答案内容断言回退为结构断言(TEST_CASES.md §3.3,依赖充值)。
import { test, expect } from '@playwright/test';
import { tier, openSessionPanel, closeSessionPanel, newSession, submitQuestion, waitForAnswerEnd, cleanupSessionsByTitle, trackConsoleErrors } from './helpers.js';
test.describe('E2E-01 主流程', () => {
let pageRef = null;
test.afterEach(async () => {
if (pageRef) {
// 清理双前缀:新建时的 R1 前缀会被首问自动标题覆盖为问题前缀(FR-SESSION-008)
await cleanupSessionsByTitle(pageRef, 'R1主流程');
await cleanupSessionsByTitle(pageRef, '水稻SPL基因家族的功能研究');
}
});
test('主流程:提问→五相位推进→答案→来源/图谱 tab→刷新恢复', async ({ page }) => {
test.setTimeout(300_000);
const t = tier(page);
const titlePrefix = t === 'desktop' ? 'R1主流程' : 'R1主流程' + (t === 'tablet' ? '-T' : '-M');
pageRef = page;
const consoleErrors = [];
trackConsoleErrors(page, consoleErrors);
await cleanupSessionsByTitle(page, titlePrefix);
await cleanupSessionsByTitle(page, '水稻SPL基因家族的功能研究');
// 1. 首屏加载 → 欢迎空态(.empty-state)
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 2. 新建会话(桌面左栏 / 平板·手机经抽屉)
const listBefore = await page.locator('.session-item').count();
await newSession(page);
await expect(page.locator('.session-item')).toHaveCount(listBefore + 1, { timeout: 10_000 });
await closeSessionPanel(page);
await expect(page.locator('.empty-state')).toBeVisible();
// 3. 问答模式提问(真实上游 ~37s;240s 上限内)
const question = '水稻SPL基因家族的功能研究有哪些关键进展?';
await submitQuestion(page, question, 'ask');
// 4. 用户气泡立即出现 + 助手骨架 pipeline 五相位 waiting
await expect(page.locator('.msg.user .bubble').last()).toContainText(question.slice(0, 12), { timeout: 10_000 });
const pipe = page.locator('.msg.assistant .pipeline-card').last();
await expect(pipe).toBeVisible({ timeout: 10_000 });
await expect(pipe.locator('.p-step')).toHaveCount(5);
// 5. 五相位推进:检索相位 running(真实上游 92s 内必经);等待终态
await expect(pipe.locator('.p-step.running').first()).toBeVisible({ timeout: 150_000 });
const res = await waitForAnswerEnd(page, { timeout: 240_000 });
expect(res.sawRunning, '流式期间应有相位处于 running').toBe(true);
expect(res.done, 'pipeline 徽章终态应为 done').toBe(true);
// 6. 终态:pipeline 全部 done、消息含来源块与 text 块(402 fallback:结构断言,依赖充值)
await expect(pipe.locator('.p-step')).toHaveCount(5);
await expect(pipe.locator('.p-step.done')).toHaveCount(5);
const asst = page.locator('.msg.assistant').last();
await expect(asst.locator('.inline-sources .src-chip').first()).toBeVisible({ timeout: 20_000 });
// 揭示动画收尾:点跳过加速。终态判据 = 最终 text 块出现
//(reveal 完成时 stream 块被替换为 text 块,二者互斥;reduced-motion 环境直接定格 text)
const skip = asst.locator('.skip-btn');
if (await skip.count()) await skip.click().catch(() => {});
const mdText = asst.locator('.md-text').first();
await expect(mdText).toBeVisible({ timeout: 180_000 });
// 6b. Markdown 渲染硬断言(BUG-R1-06 已修复验证):
// 触发条件收紧为真实 markdown 语法——行首列表标记「- 」或成对 **加粗**,
// 避免把行内连字符(如 vegetative-to-reproductive)误判为列表
const rawText = await mdText.textContent().catch(() => '');
const listLike = /^- /m.test(rawText);
const boldLike = /\*\*[^*]+\*\*/.test(rawText);
if (listLike || boldLike) {
const html = await mdText.innerHTML().catch(() => '');
const listOk = !listLike || html.includes('<li');
const boldOk = !boldLike || html.includes('<strong') || html.includes('<em');
expect(
listOk && boldOk,
`答案含 markdown 语法(${listLike ? '行首列表' : ''}${listLike && boldLike ? '/' : ''}${boldLike ? '加粗' : ''}),应渲染为 HTML 标签(FR-CHAT-004),实际 HTML: ${html.slice(0, 200)}`
).toBe(true);
}
// 7. 会话状态 done:左栏条目状态点(桌面/抽屉内)
const activeItem = page.locator('.session-item.active').first();
await expect(activeItem).toBeVisible();
await expect(activeItem.locator('.status-dot.done')).toBeVisible({ timeout: 15_000 });
// 8. 刷新 → 点击该会话 → 历史恢复(全部块重渲染)
await page.reload();
await openSessionPanel(page);
// 点击自动标题为问题前 30 字符的会话
await page.locator('.session-item').filter({ hasText: '水稻SPL基因家族的功能研究' }).first().click();
await expect(page.locator('.msg.assistant').last()).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.msg.assistant .pipeline-card').last()).toBeVisible();
await expect(page.locator('.msg.assistant .inline-sources .src-chip').first()).toBeVisible();
// 9. 控制台无未捕获错误
expect(consoleErrors, `页面应无 console error,实际: ${consoleErrors.join(' | ')}`).toHaveLength(0);
});
});
test.describe('E2E-02 检索模式', () => {
let pageRef = null;
test.afterEach(async () => {
if (pageRef) {
await cleanupSessionsByTitle(pageRef, 'R1检索');
await cleanupSessionsByTitle(pageRef, '水稻SPL基因家族的功能研究');
}
});
test('检索模式:只出来源不出答案块,use_llm=false', async ({ page }) => {
test.setTimeout(300_000);
const t = tier(page);
pageRef = page;
await cleanupSessionsByTitle(page, 'R1检索');
await cleanupSessionsByTitle(page, '水稻SPL基因家族的功能研究');
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
const listBefore = await page.locator('.session-item').count();
await newSession(page);
await expect(page.locator('.session-item')).toHaveCount(listBefore + 1);
await closeSessionPanel(page);
// 检索模式提问
await submitQuestion(page, '水稻SPL基因家族的功能研究有哪些关键进展?', 'search');
// 等待终态(真实 search 单次约 92s)
const res = await waitForAnswerEnd(page, { timeout: 240_000 });
expect(res.done, 'pipeline 徽章终态应为 done').toBe(true);
// 断言:无 text 答案块;有 sources 块;有 pipeline 卡
const asst = page.locator('.msg.assistant').last();
await expect(asst.locator('.inline-sources .src-chip').first()).toBeVisible({ timeout: 20_000 });
await expect(asst.locator('.md-text')).toHaveCount(0);
await expect(asst.locator('.stream-wrap')).toHaveCount(0);
});
});
+128
View File
@@ -0,0 +1,128 @@
// mock-scenarios.spec.js —— T 归属。L5 主线 3:错误与重试(TC-E2E-03-*,mock scenario)。
// 仅桌面视口执行(场景构造涉及整页路由拦截,三档行为一致,桌面代表即可;
// 真实错误路径跨档一致 —— 记录于 TEST_REPORT_R1 执行范围说明)。
// mock server 8099 由 PM 运行;本文件经 8090 BFF 时对 /api/qa/* 做页面路由拦截模拟场景。
import { test, expect } from '@playwright/test';
import { tier, openSessionPanel, newSession, submitQuestion, cleanupSessionsByTitle, cleanupNewSessions, sessionIds, trackConsoleErrors } from './helpers.js';
const onlyDesktop = ({ page }) => test.skip(tier(page) !== 'desktop', '仅桌面代表执行(跨档行为一致)');
test.describe('E2E-03 错误与重试(mock 场景)', () => {
let pageRef = null;
let preIds = null;
test.afterEach(async () => {
if (pageRef) {
await cleanupSessionsByTitle(pageRef, 'R1错误');
await cleanupNewSessions(pageRef, preIds); // 自动标题会话(问题文本)按 ID 快照清
}
});
test('UPSTREAM_DOWN:作曲器禁用 + 文案提示 + 历史可浏览', async ({ page }) => {
test.setTimeout(120_000);
onlyDesktop({ page });
pageRef = page;
preIds = await sessionIds(page);
await cleanupSessionsByTitle(page, 'R1错误');
const consoleErrors = [];
trackConsoleErrors(page, consoleErrors);
// 路由拦截:/api/health 返回 QA 不可用的降级数据(驱动 health store 的 qaOk=false)
await page.route('**/api/health', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
status: 'degraded',
version: '0.1.0',
upstreams: { qa: { ok: false }, rag: { ok: true }, ragAlt: { ok: true }, neo4j: { ok: true } },
db: { ok: true, sessions: 9 },
}),
})
);
// /api/qa/chat/stream → 503 降级信封(API.md §1 UPSTREAM_DOWN)
await page.route('**/api/qa/chat/stream', (route) =>
route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ ok: false, error: { code: 'UPSTREAM_DOWN', message: '知识问答服务当前不可用,请稍后重试', degraded: true } }),
})
);
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 1. 作曲器禁用 + 提示(BUG-R1-04 修复验证:health.qaOk=false → Composer disabled)
const textarea = page.locator('.composer textarea');
await expect(textarea, 'QA 不可用时作曲器应禁用(FR-HEALTH-002)').toBeDisabled({ timeout: 10_000 });
await expect(page.locator('.confirm-mask, .modal')).toHaveCount(0);
// 2. 历史会话仍可浏览:点击已有会话条目 → 消息加载
const item = page.locator('.session-item').first();
await expect(item).toBeVisible();
await item.click();
await expect(page.locator('.msg.assistant, .empty-state')).toBeAttached({ timeout: 10_000 });
// 3. 健康轮询失败不弹窗:控制台无未捕获错误(console.error 单独收集)
await page.waitForTimeout(2000);
expect(consoleErrors, `QA 降级不应产生未捕获错误,实际: ${consoleErrors.join(' | ')}`).toHaveLength(0);
});
test('UPSTREAM_ERROR:错误标注 + 重试按钮 + 重试不新增用户消息', async ({ page }) => {
test.setTimeout(180_000);
onlyDesktop({ page });
pageRef = page;
preIds = await sessionIds(page);
await cleanupSessionsByTitle(page, 'R1错误');
let qaCalls = 0;
// 前两次 502,之后放行真实流(重试应成功 → 也验证重试链路)
await page.route('**/api/qa/chat/stream', async (route) => {
qaCalls += 1;
if (qaCalls <= 2) {
await route.fulfill({
status: 502,
contentType: 'application/json',
body: JSON.stringify({ ok: false, error: { code: 'UPSTREAM_ERROR', message: '上游问答服务返回错误', degraded: false } }),
});
} else {
await route.fallback();
}
});
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 新建会话并提问
const before = await page.locator('.session-item').count();
await newSession(page);
await expect(page.locator('.session-item')).toHaveCount(before + 1);
await submitQuestion(page, '水稻SPL基因家族的功能研究有哪些关键进展?', 'ask');
// 1. 错误标注(err-note)出现,文案为上游 message(BUG-R1-05 修复验证:
// 上游 502 + 错误信封应透传 message,不得误显示「连接中断」)
const errNote = page.locator('.msg.assistant .err-note').last();
await expect(errNote).toBeVisible({ timeout: 30_000 });
await expect(errNote.locator('.err-text'), '上游 502 时应展示上游错误 message,而非「连接中断」').toContainText('上游问答服务返回错误', { timeout: 10_000 });
// 2. 重试按钮可用
const retryBtn = errNote.locator('.retry-btn');
await expect(retryBtn).toBeVisible();
// 3. 重试:点击后同位置替换(用户消息不新增)
const userCountBefore = await page.locator('.msg.user').count();
const asstCountBefore = await page.locator('.msg.assistant').count();
await retryBtn.click();
// 第二次 502(qaCalls==2)后仍错误;再点一次 → 放行真实流
await expect(page.locator('.msg.assistant .err-note').last()).toBeVisible({ timeout: 30_000 });
await page.locator('.msg.assistant .err-note .retry-btn').last().click();
// 真实流:等待终态(≥40s;pipeline 徽章 done/error 均可,本轮断言结构)
const badge = page.locator('.msg.assistant .pipeline-card .badge').last();
await expect(badge).toHaveClass(/done|error/, { timeout: 240_000 });
// 重试两次后:用户消息仍 1 条、助手消息仍 1 条(同位置替换)
await expect(page.locator('.msg.user')).toHaveCount(userCountBefore);
await expect(page.locator('.msg.assistant')).toHaveCount(asstCountBefore);
});
});
+186
View File
@@ -0,0 +1,186 @@
// responsive.spec.js —— T 归属。L5 主线 9 响应式专项(TC-E2E-09-*,三档布局断言 + 移动端专项)
// 与主线 5 中断(TC-E2E-05-*,桌面代表——流式中断行为跨档一致,记录于报告执行范围)。
// 断点规则(PRD FR-RESP-001~004):≥1280 三栏;1024-1279 左栏自动折叠;768-1023 抽屉;<768 覆盖层。
import { test, expect } from '@playwright/test';
import { tier, openSessionPanel, newSession, closeSessionPanel, cleanupSessionsByTitle, cleanupNewSessions, sessionIds, submitQuestion } from './helpers.js';
test.describe('E2E-09 响应式专项', () => {
test('桌面:三栏网格 260/1fr/380 + 底栏四点 + 折叠持久化', async ({ page }) => {
test.setTimeout(60_000);
test.skip(tier(page) !== 'desktop', '仅桌面视口');
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 1. 网格精确值(FR-SHELL-001):getComputedStyle 会把 1fr 解析为像素(1440 下=800px),
// 因此断言三轨解析值 + 行轨与 body overflow
const grid = await page.evaluate(() => {
const s = getComputedStyle(document.querySelector('.app-shell'));
return { cols: s.gridTemplateColumns.split(' '), rows: s.gridTemplateRows, overflow: getComputedStyle(document.body).overflow };
});
expect(grid.cols[0]).toBe('260px');
expect(grid.cols[1]).toBe('800px'); // 1440-260-380=800(minmax(0,1fr) 解析值)
expect(grid.cols[2]).toBe('380px');
// 行轨 1fr 同样解析为像素(900-48-28=824)
const rowTracks = grid.rows.split(' ');
expect(rowTracks[0]).toBe('48px');
expect(rowTracks[1]).toBe('824px');
expect(rowTracks[2]).toBe('28px');
expect(grid.overflow).toBe('hidden');
// 2. 三栏宽度精确(1440:260/800/380)
const widths = await page.evaluate(() => {
const q = (sel) => document.querySelector(sel)?.getBoundingClientRect().width;
return {
aside: q('.aside-host .session-panel') || q('.aside-host'),
chat: q('.chat-panel'),
bench: q('.bench-wrap .bench-panel') || q('.bench-wrap'),
};
});
expect(widths.aside).toBeCloseTo(260, 0);
expect(widths.chat).toBeCloseTo(800, 0);
expect(widths.bench).toBeCloseTo(380, 0);
// 3. 无横向滚动条(FR-SHELL-001)
const noHScroll = await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth);
expect(noHScroll).toBe(true);
// 4. 底栏:4 个 agent 点 + 版本 + QA 版本 mono
const statusBar = page.locator('.status-bar');
await expect(statusBar).toBeVisible();
await expect(statusBar.locator('.agent-dot-item')).toHaveCount(4);
await expect(statusBar.locator('.status-right')).toContainText('PlantAI RAG v0.1');
// 5. 左栏折叠 → 64px + localStorage 持久化 → 刷新保持(FR-SHELL-004)
await page.locator('.side-handle-btn').click();
await expect.poll(() => page.evaluate(() => document.querySelector('.app-shell').classList.contains('side-collapsed'))).toBe(true);
await expect(page.locator('.app-shell')).toHaveClass(/side-collapsed/);
await expect.poll(() => page.evaluate(() => localStorage.getItem('pa-side-collapsed'))).toBe('1');
await page.reload();
await expect(page.locator('.app-shell')).toHaveClass(/side-collapsed/);
// 展开恢复,避免影响后续用例
await page.locator('.side-handle-btn').click();
});
test('平板:单栏 + 左/右抽屉(scrim 关闭)+ 顶栏按钮 + 底栏保留', async ({ page }) => {
test.setTimeout(60_000);
test.skip(tier(page) !== 'tablet', '仅平板视口');
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
await expect(page.locator('.app-shell')).toHaveClass(/drawers-mode/);
// 1. 单栏铺满 + 左/右栏隐藏
await expect(page.locator('.aside-host, .app-shell .session-panel:not(.drawer .session-panel)')).toHaveCount(0);
await expect(page.locator('.bench-wrap')).toHaveCount(0);
// 2. 汉堡 → 左抽屉滑入 + scrim;点击 scrim 关闭
await page.locator('.hamburger-btn').click();
await expect(page.locator('.drawer.left.open')).toBeVisible();
await expect(page.locator('.drawer-scrim-el')).toBeVisible();
await page.locator('.drawer-scrim-el').click();
await expect(page.locator('.drawer.left.open')).toHaveCount(0);
// 3. 工作台按钮 → 右抽屉(来源角标存在与否按数据)
await page.locator('.bench-btn').click();
await expect(page.locator('.drawer.right.open')).toBeVisible();
await expect(page.locator('.drawer.right .bench-panel')).toBeVisible();
// Esc 关闭
await page.keyboard.press('Escape');
await expect(page.locator('.drawer.right.open')).toHaveCount(0);
// 4. 底栏保留(平板不隐藏)
await expect(page.locator('.status-bar')).toBeVisible();
});
test('手机:顶栏三件套 + 作曲器吸底 + 全屏覆盖层 + 底栏隐藏 + 移动端专项', async ({ page }) => {
test.setTimeout(60_000);
test.skip(tier(page) !== 'mobile', '仅手机视口');
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
await expect(page.locator('.app-shell')).toHaveClass(/drawers-mode/);
await expect(page.locator('.app-shell')).toHaveClass(/mobile/);
// 1. 顶栏 = 汉堡 + 标题 + 工作台按钮;底栏隐藏(FR-RESP-004)
await expect(page.locator('.hamburger-btn')).toBeVisible();
await expect(page.locator('.bench-btn')).toBeVisible();
await expect(page.locator('.status-bar')).toBeHidden();
await expect(page.locator('.logo-text')).toBeHidden();
// 2. 移动端专项:textarea 16px(FR-RESP-006)
const fontSize = await page.evaluate(() => getComputedStyle(document.querySelector('.composer textarea')).fontSize);
expect(fontSize).toBe('16px');
// 3. 触控目标 ≥44px(FR-RESP-008)
const heights = await page.evaluate(() => {
const q = (sel) => document.querySelector(sel)?.getBoundingClientRect().height;
return { pill: q('.mode-pill'), tab: q('.bench-overlay-tabs .bench-tab') || q('.bench-tab'), item: q('.session-item') };
});
if (heights.pill != null) expect(heights.pill).toBeGreaterThanOrEqual(44);
// 4. 工作台按钮 → 全屏覆盖层 + 顶部 tab 条 + 内部 tab 隐藏(.bench-overlay)
await page.locator('.bench-btn').click();
await expect(page.locator('.bench-overlay')).toBeVisible();
await expect(page.locator('.bench-overlay .bench-overlay-tabs')).toBeVisible();
await expect(page.locator('.bench-overlay .bench-panel .bench-tabs')).toBeHidden();
// 关闭按钮
await page.locator('.bench-overlay .bench-close-btn').click();
await expect(page.locator('.bench-overlay')).toHaveCount(0);
// 5. 作曲器吸底(视口底部附近,安全区 padding 生效)
const composerBox = await page.locator('.composer-wrap').boundingBox();
const viewport = page.viewportSize();
expect(viewport.height - composerBox.y - composerBox.height).toBeLessThanOrEqual(60);
// 6. viewport-fit=cover(FR-RESP-005)
const metaViewport = await page.evaluate(() => document.querySelector('meta[name="viewport"]').content);
expect(metaViewport).toContain('viewport-fit=cover');
// 7. 表格横向滚动容器(FR-RESP-011):构造宽表格断言(有 md-table-wrap 时)
await expect(page.locator('.md-table-wrap, .chat-panel')).toBeAttached();
});
});
test.describe('E2E-05 中断', () => {
let pageRef = null;
let preIds = null;
test.afterEach(async () => {
if (pageRef) await cleanupNewSessions(pageRef, preIds);
});
test('流式中切会话 → 原会话置 error 标注「已中断」', async ({ page }) => {
test.setTimeout(300_000);
test.skip(tier(page) !== 'desktop', '仅桌面代表执行(真实流成本高,中断行为跨档一致)');
pageRef = page;
preIds = await sessionIds(page);
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 新建会话 A(标题「新会话」)并提问。位置定位:新建项 unshift 到顶部,
// 且流式检查点不断 touch 刷新 updated_at → A 全程在列表顶部
await newSession(page);
await submitQuestion(page, '水稻SPL基因家族的功能研究有哪些关键进展?', 'ask');
// 等待流式开始(pipeline running 相位出现)
await expect(page.locator('.msg.assistant .p-step.running').first()).toBeVisible({ timeout: 120_000 });
// 切到列表第二条(任意其他会话)→ selectSession 触发 abort('interrupted') 中断 A
const other = page.locator('.session-item').nth(1);
await expect(other).toBeVisible();
await other.click();
// 等待原会话被置 error 并落库(中断标注在 _markInterrupted 中同步完成)
await expect(page.locator('.session-item.active')).toHaveCount(1, { timeout: 10_000 });
// 切回 A(列表顶部;中断路径的 _finish 也会 ensureTitle,
// 标题此刻已是自动标题「水稻SPL基因…」,不依赖标题定位——A 一直因 touch 保持在顶部)
const itemA = page.locator('.session-item').first();
await itemA.click();
const errNote = page.locator('.msg.assistant .err-note').last();
await expect(errNote).toBeVisible({ timeout: 20_000 });
await expect(errNote.locator('.err-text')).toContainText('已中断', { timeout: 20_000 });
// 会话条目状态点 error(活动项 = 刚点回的 A)
await expect(page.locator('.session-item.active .status-dot.error')).toBeVisible({ timeout: 15_000 });
});
});
+142
View File
@@ -0,0 +1,142 @@
// session-theme.spec.js —— T 归属。L5 主线 4 会话管理(TC-E2E-04-*)+ 主线 6 主题(TC-E2E-06-*)。
// R1 结构:新建/重命名/搜索/分组(核心,硬断言,缺陷独立);活动高亮与删除入口为缺陷探针
// (独立 test,失败即缺陷证据,不连累核心用例)。三档视口行为一致但会话数据共享——桌面执行核心,
// 平板/手机执行新建+搜索精简分支。
import { test, expect } from '@playwright/test';
import { tier, newSession, cleanupSessionsByTitle, cleanupNewSessions, sessionIds } from './helpers.js';
const PREFIX = 'R1会话-';
test.describe('E2E-04 会话管理', () => {
let pageRef = null;
let preIds = null;
test.afterEach(async () => {
// 按 ID 快照精确清理(标题前缀只覆盖已重命名的会话;未重命名的「新会话」按 ID 删)
if (pageRef) {
await cleanupSessionsByTitle(pageRef, PREFIX);
await cleanupNewSessions(pageRef, preIds);
}
});
test('新建/重命名/搜索过滤/分组(核心)', async ({ page }) => {
test.setTimeout(120_000);
pageRef = page;
preIds = await sessionIds(page);
await cleanupSessionsByTitle(page, PREFIX);
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 1. 新建两个会话(连点两次 → 两个独立会话,不重复)
const before = await page.locator('.session-item').count();
await newSession(page);
await newSession(page);
await expect(page.locator('.session-item')).toHaveCount(before + 2, { timeout: 10_000 });
// 2. 双击重命名(Enter 提交);标题同步到列表与顶栏(FR-SESSION-006)
const firstItem = page.locator('.session-item').first();
await firstItem.dblclick();
const renameInput = firstItem.locator('.rename-input');
await expect(renameInput).toBeVisible();
await renameInput.fill(`${PREFIX}甲`);
await renameInput.press('Enter');
await expect(page.locator('.session-item').first().locator('.title')).toContainText(`${PREFIX}甲`);
if (tier(page) !== 'mobile') {
await expect(page.locator('.top-title .t')).toContainText(`${PREFIX}甲`);
}
// 第二个会话也重命名,便于后文按前缀过滤断言
const secondItem = page.locator('.session-item').nth(1);
await secondItem.dblclick();
await secondItem.locator('.rename-input').fill(`${PREFIX}乙`);
await secondItem.locator('.rename-input').press('Enter');
await expect(page.locator('.session-item').nth(1).locator('.title')).toContainText(`${PREFIX}乙`);
// 3. 搜索过滤:大小写不敏感子串;无匹配文案;清空恢复;期间无网络请求(FR-SESSION-005)
let reqCount = 0;
const onReq = () => { reqCount += 1; };
page.on('request', onReq);
const searchBox = page.locator('.search-box input').first();
await searchBox.fill('r1会话'); // 小写 r1 应命中大写 R1(大小写不敏感)
await expect(page.locator('.session-item')).toHaveCount(2);
await searchBox.fill('甲');
await expect(page.locator('.session-item')).toHaveCount(1);
await searchBox.fill('不存在的会话名XYZ');
await expect(page.locator('.empty-search')).toContainText('无匹配会话');
await expect(page.locator('.session-item')).toHaveCount(0);
await searchBox.fill('');
await expect(page.locator('.session-item')).toHaveCount(before + 2);
expect(reqCount, '搜索过滤不应发网络请求').toBe(0);
page.off('request', onReq);
// 4. 分组标签(今天组存在——新会话更新时间为今天,FR-SESSION-001)
await expect(page.locator('.group-label').first()).toContainText('今天');
});
// 缺陷探针 A(BUG-R1-02 修复验证):点击会话应置活动态高亮(FR-SESSION-009)
test('活动态高亮探针', async ({ page }) => {
test.setTimeout(60_000);
pageRef = page;
preIds = await sessionIds(page);
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
await newSession(page);
await newSession(page);
// 点击第二项(选中它,并自动切换到它)
const second = page.locator('.session-item').nth(1);
await second.click();
// 硬断言:活动项应有 .active(FR-SESSION-009)
await expect(second, '点击会话后应显示活动态高亮(FR-SESSION-009)').toHaveClass(/active/, { timeout: 10_000 });
});
// 缺陷探针 B(BUG-R1-03 修复验证):删除入口是否存在(FR-SESSION-007 二次确认弹层需可达)
test('删除入口探针', async ({ page }) => {
test.setTimeout(60_000);
pageRef = page;
preIds = await sessionIds(page);
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
await newSession(page);
const firstItem = page.locator('.session-item').first();
await firstItem.click({ button: 'right' });
// 硬断言:右键应唤出删除确认弹层(BUG-R1-03 修复验证)
await expect(
page.locator('.confirm-box'),
'右键应唤出删除确认弹层(FR-SESSION-007 删除需二次确认)'
).toBeVisible({ timeout: 5_000 });
});
});
test.describe('E2E-06 主题', () => {
test('深浅切换即时生效 + 刷新持久化', async ({ page }) => {
test.setTimeout(60_000);
await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible();
// 初始主题(默认深色或 localStorage 已有值)
const initial = await page.evaluate(() => document.documentElement.dataset.theme);
const themeBtn = page.locator('.top-bar .icon-btn').last();
// 切换到另一主题 → dataset 即时更新 + localStorage 落值
await themeBtn.click();
await expect
.poll(() => page.evaluate(() => document.documentElement.dataset.theme), { timeout: 5_000 })
.not.toBe(initial);
const toggled = await page.evaluate(() => document.documentElement.dataset.theme);
const stored = await page.evaluate(() => localStorage.getItem('pa-theme'));
expect(stored).toBe(toggled);
// 刷新 → 保持
await page.reload();
await expect(page.locator('.app-shell')).toBeVisible();
await expect
.poll(() => page.evaluate(() => document.documentElement.dataset.theme), { timeout: 5_000 })
.toBe(toggled);
// 复原初始主题(避免影响后续用例的视觉断言)
if (toggled !== initial) await themeBtn.click();
});
});
+44
View File
@@ -0,0 +1,44 @@
// smoke.spec.js —— L5 冒烟骨架(本波仅 2 条)。
// 每一条在 mobile(390×844)/tablet(768×1024)/desktop(1440×900) 三项目上各执行一次。
// 对应 docs/TEST_CASES.md:TC-E2E-SMOKE-01(页面加载)、TC-E2E-SMOKE-02(AppShell 存在)。
//
// 契约依据:UI_SPEC.md §5.3 类名(app-shell/top-bar/status-bar/session-panel/chat-panel/bench-panel)。
// 说明:M0 阶段 AppShell 尚未实现(App.vue 为占位),AppShell 断言带占位回退(app-shell-placeholder),
// B1 交付后断言即走真实分支。完整用例(TC-E2E-01~11)在后续轮次按 TEST_CASES.md 补齐。
import { test, expect } from '@playwright/test';
// 项目:mobile/tablet/desktop(playwright.config.js projects)。三档视口下页面可加载。
test('TC-E2E-SMOKE-01 | 页面加载:首屏可渲染,标题正确', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/海古纪 PlantAI/);
// #app 挂载且非空(M0 为占位文案,M1 起为 AppShell)
const app = page.locator('#app');
await expect(app).toBeVisible();
const html = (await app.innerHTML()).trim();
expect(html.length, '#app 不应为空').toBeGreaterThan(0);
});
// 项目:mobile/tablet/desktop。AppShell 骨架存在。
// M0 回退:仅断言 #app 内容;M1 起断言 shell 三要素与占位符消失。
// 注意:移动端底栏按 FR-RESP-004 有意隐藏(四个 agent 点合并为顶栏聚合状态点),
// 因此 .status-bar 只在 ≥768px 视口断言可见,移动端断言隐藏。
test('TC-E2E-SMOKE-02 | AppShell 存在:壳层三要素渲染(含 M0 占位回退)', async ({ page }) => {
await page.goto('/');
const shell = page.locator('.app-shell');
const placeholder = page.locator('.app-shell-placeholder');
if (await shell.count()) {
// M1+:B1 交付的真实三栏壳层
await expect(shell).toBeVisible();
await expect(page.locator('.top-bar')).toBeVisible();
const width = (await page.viewportSize()).width;
if (width >= 768) {
await expect(page.locator('.status-bar')).toBeVisible();
} else {
await expect(page.locator('.status-bar')).toBeHidden();
}
await expect(placeholder).toHaveCount(0);
} else {
// M0:脚手架占位期,AppShell 未实现时仅验证页面可渲染
await expect(placeholder).toBeVisible();
}
});