// 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))); }