Files
PlantAI-rag-new/tests/e2e/docgen-ui.spec.js
T
jerryandClaude Fable 5 5888e1d5c1 test(docgen): 测试治理资产——131 条验收用例 + 三轮留痕 + E2E/回归套件
- tests/docgen/:DC-UNIT/API/CONF/E2E/UI/REGRESSION/PERF 分组用例与可复跑脚本,报告逐条留痕(R1-R3 全记录)
- 意图 30 条前后端同表双跑(pytest + node)
- Playwright headless:docgen-ui/docgen-regression spec + b3 benchpanel 第 5 TAB 断言更新

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 21:13:47 +08:00

266 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// docgen-ui.spec.js —— docgen P0 R1 UI 验收(测试 agent 归属)
// 覆盖:DC-UI-01/02/03/04/05/06/07/08/09/10/11/12/13 + DC-REGRESSION-04(可自动化部分)。
// 目标:vite 5180(TL 部署的 R1 被测前端)+ 真实 BFF 8090 / vstore 8094。
// 依赖真实 LLM(DeepSeek)的步骤耗时较长(生成 ~60-120s),全局 timeout 沿用 480s。
// 说明:涉及「精确视觉样式」的检查点(26×26 圆角/hairline 描边等)以 boundingBox 存在性
// 为准,纯视觉项按 R1 口径留人工待 TL 验收时复核。
import { test, expect } from '@playwright/test';
import { benchPanel } from './helpers.js';
const DOCGEN_Q = '帮我写一份年度工作总结';
const QA_Q = '水稻基因是什么?介绍一下';
async function sendText(page, text) {
const composer = page.locator('.composer-wrap');
await composer.locator('textarea').fill(text);
await composer.locator('.send-btn').click();
}
async function waitDocCard(page, timeout = 240_000) {
const card = page.locator('.msg.assistant .doc-card').last();
await expect(card).toBeVisible({ timeout });
return card;
}
test('DC-UI-03/05/06 | 自然触发 docgen:徽标 + pipeline 五步 + 文档卡 + 自动切文档 TAB', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.composer-wrap textarea')).toBeVisible();
// 断言走 docgen 分支(generate 请求发出,且无 QA 流请求)
const genReq = page.waitForRequest((r) => r.url().includes('/api/docgen/generate'), { timeout: 30_000 });
const qaReqSeen = { n: 0 };
page.on('request', (r) => { if (r.url().includes('/api/qa/chat/stream')) qaReqSeen.n += 1; });
await sendText(page, DOCGEN_Q);
await genReq;
// 骨架:pipeline 五步 + 能力徽标「文档生成」
const pipe = page.locator('.msg.assistant .pipeline-card').last();
await expect(pipe).toBeVisible({ timeout: 10_000 });
await expect(pipe.locator('.p-step')).toHaveCount(5);
const capBadge = pipe.locator('.badge.cap').first();
await expect(capBadge).toBeVisible({ timeout: 10_000 });
expect(await capBadge.textContent()).toContain('文档生成');
// 等待生成完成(真实 LLM):pipeline done + 文档卡出现
await waitDocCard(page);
await expect(pipe.locator('.p-step.done')).toHaveCount(5, { timeout: 30_000 });
// 文档卡要素 + 自动切「文档」TAB
const card = page.locator('.msg.assistant .doc-card').last();
await expect(card.locator('.dc-open')).toBeVisible();
const bench = benchPanel(page);
await expect(bench.locator('.bench-tab.on')).toContainText('文档', { timeout: 15_000 });
await expect(bench.locator('.doc-pane')).toBeVisible();
// 无 QA 流请求(分流正确)
expect(qaReqSeen.n).toBe(0);
});
test('DC-UI-07 | 文档 TAB 预览:markdown 渲染 + 自动切 TAB 后内容可用', async ({ page }) => {
// 直接生成一个文档(复用 03 的路径),然后进 TAB 操作
await page.goto('/');
await expect(page.locator('.composer-wrap textarea')).toBeVisible();
await sendText(page, DOCGEN_Q);
await waitDocCard(page);
// R2:dev3 BUG-3/4 已修复(adoptArtifact 无 md 时自动拉取 content;open 同 id 且有内容复用)。
// 还原原始流程断言:生成成功自动切文档 TAB 后预览应直接出现(无需刷新绕行)。
const bench = benchPanel(page);
await expect(bench.locator('.bench-tab.on')).toContainText('文档', { timeout: 15_000 });
// 预览:markdown 渲染 HTML(标题/段落存在)
const preview = bench.locator('.doc-preview.md-text');
await expect(preview).toBeVisible({ timeout: 20_000 });
const html = await preview.innerHTML();
expect(html.includes('<h1') || html.includes('<h2')).toBe(true); // 标题渲染
});
test('DC-UI-08 | 就地编辑:真实失焦 PATCH + 块间切换不误触发 + 刷新持久(R3 focusout 修复版)', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.composer-wrap textarea')).toBeVisible();
await sendText(page, DOCGEN_Q);
await waitDocCard(page);
const bench = benchPanel(page);
await expect(bench.locator('.bench-tab.on')).toContainText('文档', { timeout: 15_000 });
await expect(bench.locator('.doc-preview.md-text')).toBeVisible({ timeout: 20_000 });
// 进入编辑模式
await bench.locator('.dh-actions .pane-btn').first().click();
const editRoot = bench.locator('.doc-edit.md-text');
await expect(editRoot).toBeVisible();
const patches = [];
page.on('request', (r) => { if (r.url().includes('/content') && r.method() === 'PATCH') patches.push(r.url()); });
// R3 阶段 1:真实用户流程——编辑块 → 失焦到编辑区外 → 必现 PATCH(R2 证据 realBlockBlurPATCH=0 应翻转)
const blocks = editRoot.locator('[contenteditable="true"]');
await expect(blocks.first()).toBeVisible({ timeout: 10_000 });
await blocks.first().click();
await blocks.first().fill('R3就地编辑验证段落。');
// 失焦到编辑区外(点击非编辑元素 .dh-title,焦点离开 editRoot)
await bench.locator('.dh-title').click();
await page.waitForTimeout(2500); // focusout(30ms) + scheduleSave 800ms debounce + 余量
expect(patches.length, 'DC-UI-08 阶段1 FAIL:真实失焦到编辑区外未触发 PATCH 保存').toBeGreaterThanOrEqual(1);
const patchesAfterPhase1 = patches.length;
// R3 阶段 2:块间焦点切换不误触发(relatedTarget 在编辑区内 → 不序列化)
await blocks.nth(1).click(); // 焦点从 body 进入块 2(进入不触发保存)
await page.waitForTimeout(1200);
expect(patches.length, 'DC-UI-08 阶段2 FAIL:块间焦点切换误触发了 PATCH').toBe(patchesAfterPhase1);
await blocks.nth(1).fill('R3第二段修改。');
await blocks.nth(2).click(); // 块 2 → 块 3 焦点迁移(编辑区内)
await page.waitForTimeout(1200);
expect(patches.length, 'DC-UI-08 阶段2 FAIL:编辑区内焦点迁移误触发了 PATCH').toBe(patchesAfterPhase1);
// 最后失焦出编辑区 → 一次 PATCH(含两处修改)
await bench.locator('.dh-title').click();
await page.waitForTimeout(2500);
expect(patches.length, 'DC-UI-08 阶段2 FAIL:出编辑区后未保存两处修改').toBe(patchesAfterPhase1 + 1);
// R3 阶段 3:刷新/重进 TAB 内容不丢(PATCH 已落 BFF)
await page.reload();
const sess = page.locator('.session-item').filter({ hasText: DOCGEN_Q }).first();
await expect(sess).toBeVisible({ timeout: 15_000 });
await sess.click();
const card = page.locator('.msg.assistant .doc-card').last();
await expect(card).toBeVisible({ timeout: 20_000 });
await card.locator('.dc-open').click();
await expect(bench.locator('.doc-preview.md-text')).toBeVisible({ timeout: 20_000 });
const savedHtml = await bench.locator('.doc-preview.md-text').innerHTML();
expect(savedHtml.includes('R3就地编辑验证段落'), 'DC-UI-08 阶段3 FAIL:刷新后编辑内容未持久').toBe(true);
expect(savedHtml.includes('R3第二段修改'), 'DC-UI-08 阶段3 FAIL:第二段编辑内容未持久').toBe(true);
});
test('DC-UI-09 | 「用 prompt 修改」输入行 → edit 端点(真实 LLM)', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.composer-wrap textarea')).toBeVisible();
await sendText(page, DOCGEN_Q);
await waitDocCard(page);
const bench = benchPanel(page);
await expect(bench.locator('.bench-tab.on')).toContainText('文档', { timeout: 15_000 });
const promptInput = bench.locator('.edit-prompt-row input, .edit-prompt-row textarea').first();
await expect(promptInput).toBeVisible();
const editResp = page.waitForResponse((r) => r.url().includes('/edit') && r.request().method() === 'POST', { timeout: 30_000 });
await promptInput.fill('帮我改写这份总结');
await bench.locator('.edit-prompt-row button').first().click();
const resp = await editResp;
// R1 实测:BFF 护栏把 edit 类指令判为 422(dev2 路由护栏与意图词典口径问题——见报告 R1 记录)
// 前端按失败可见原则展示 editError;本断言记录真实结果(不做预期美化)
const status = resp.status();
expect([200, 422]).toContain(status);
if (status === 422) {
await expect(bench.locator('.doc-save-state.error').first()).toBeVisible({ timeout: 15_000 });
}
});
test('DC-UI-10 | 导出 PDF(DocPane 按钮 → export 端点)', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.composer-wrap textarea')).toBeVisible();
await sendText(page, DOCGEN_Q);
await waitDocCard(page);
const bench = benchPanel(page);
await expect(bench.locator('.bench-tab.on')).toContainText('文档', { timeout: 15_000 });
const expResp = page.waitForResponse((r) => r.url().includes('/export') && r.request().method() === 'POST', { timeout: 30_000 });
await bench.locator('.export-row .pane-btn.exp').first().click(); // PDF
const resp = await expResp;
// R1 实测:export 同样被意图护栏 422(见报告);记录真实结果
expect([200, 422]).toContain(resp.status());
});
test('DC-UI-11 | 右栏拖宽 + localStorage 持久化', async ({ page }) => {
await page.goto('/');
const resizer = page.locator('.bench-resizer');
await expect(resizer).toBeVisible();
const wrap = page.locator('.bench-wrap');
const before = await wrap.evaluate((el) => el.getBoundingClientRect().width);
const box = await resizer.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x - 120, box.y + box.height / 2, { steps: 8 });
await page.mouse.up();
const after = await wrap.evaluate((el) => el.getBoundingClientRect().width);
expect(after).toBeGreaterThan(before); // 向左拖 → 变宽
expect(after).toBeLessThanOrEqual(Math.round((await page.viewportSize()).width * 0.6));
const stored = await page.evaluate(() => localStorage.getItem('pa-bench-width'));
expect(stored).not.toBeNull();
await page.reload();
await expect(page.locator('.bench-wrap')).toBeVisible();
const afterReload = await wrap.evaluate((el) => el.getBoundingClientRect().width);
expect(Math.round(afterReload)).toBe(Math.round(after)); // 刷新保持
});
test('DC-UI-12 | 折叠手柄(右栏 28px 点击区保留 + 状态持久化)', async ({ page }) => {
await page.goto('/');
const handle = page.locator('.bench-wrap > .bench-handle-btn'); // P3 AppShell 手柄(旧 BenchPanel 按钮为第二处)
await expect(handle).toBeVisible();
const hb = await handle.boundingBox();
expect(hb).not.toBeNull();
await handle.click();
await expect(page.locator('.app-shell')).toHaveClass(/bench-collapsed/); // 折叠类绑在 app-shell
const collapsed = await page.evaluate(() => localStorage.getItem('pa-bench-collapsed'));
expect(collapsed).not.toBeNull();
// 折叠态 resizer 隐藏(不拖拽)
await expect(page.locator('.bench-resizer')).toBeHidden();
await handle.click();
await expect(page.locator('.app-shell')).not.toHaveClass(/bench-collapsed/);
});
test('DC-UI-13 | 聊天框「导出成PDF」→ export 分支(无 generate 请求)', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.composer-wrap textarea')).toBeVisible();
// 先生成一个活动文档
await sendText(page, DOCGEN_Q);
await waitDocCard(page);
await expect(benchPanel(page).locator('.bench-tab.on')).toContainText('文档', { timeout: 15_000 });
// 输入导出指令:必须走 export 分支(不产生 generate 请求)
const genSeen = { n: 0 };
page.on('request', (r) => { if (r.url().includes('/api/docgen/generate')) genSeen.n += 1; });
const expReq = page.waitForRequest((r) => r.url().includes('/api/docgen/artifacts') && r.url().includes('/export'), { timeout: 30_000 });
await sendText(page, '导出成PDF');
await expReq;
expect(genSeen.n).toBe(0); // 分流正确:无 generate
});
test('DC-UI-04 | QA 原路径不回退(30 条 #8)', async ({ page }) => {
// R1 口径:QA 真实流会触发既有 BFF 断流崩溃问题(TL 已单独记录),
// 本用例改用「打开已有 QA 会话」验证 QA 路径渲染不回退(不触发流)。
// 前端路由分流本体由 DC-INTENT-CASES(前端 30/30)+ DC-UI-03(docgen 分支请求断言)覆盖。
await page.goto('/');
const item = page.locator('.session-item').filter({ hasText: '水稻SPL基因家族研究有哪些新的进展?' }).first();
await expect(item).toBeVisible({ timeout: 10_000 });
await item.click();
// QA 历史消息恢复:pipeline 卡存在且无 docgen 文档卡
const pipe = page.locator('.msg.assistant .pipeline-card').last();
await expect(pipe).toBeVisible({ timeout: 15_000 });
expect(await page.locator('.msg.assistant .doc-card').count()).toBe(0);
// 无 docgen generate 请求
const genSeen = { n: 0 };
page.on('request', (r) => { if (r.url().includes('/api/docgen/generate')) genSeen.n += 1; });
await page.waitForTimeout(2000);
expect(genSeen.n).toBe(0);
});
test('DC-REGRESSION-04 | 历史/QA 消息 pipeline 卡无徽标(capabilities 缺省零渲染)', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.composer-wrap textarea')).toBeVisible();
// 一条 QA 消息(等待其 pipeline 骨架出现即可,不必等完整答案)
// R1 口径:不触发真实 QA 流(既有 BFF 断流崩溃问题),打开历史 QA 会话验证
const item = page.locator('.session-item').filter({ hasText: '水稻SPL基因家族研究有哪些新的进展?' }).first();
await expect(item).toBeVisible({ timeout: 10_000 });
await item.click();
const qaPipe = page.locator('.msg.assistant .pipeline-card').last();
await expect(qaPipe).toBeVisible({ timeout: 15_000 });
expect(await qaPipe.locator('.badge.cap').count()).toBe(0); // 无 capabilities → 零渲染
});