- 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>
191 lines
7.4 KiB
JavaScript
191 lines
7.4 KiB
JavaScript
// 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)));
|
||
}
|