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