/** * 专项验证 RecordDetailModal 内部交互(改完排版后回归): * - 点击参考素材 thumb 弹 lightbox * - 点击 thumb 内下载按钮不触发 lightbox(stopPropagation) * - hover thumb 时 title 属性带完整 label * - 无参考素材时左侧只有视频不崩 * - max-height 滚动(模拟 8+ refs - 注:实测数据可能没这么多,只验逻辑) */ import { chromium } from '@playwright/test'; const BASE = 'http://localhost:5173'; const API = 'http://localhost:8000'; const results = []; function pass(name) { results.push({ name, ok: true }); console.log(` ✓ ${name}`); } function fail(name, err) { results.push({ name, ok: false, err: err?.message || err }); console.log(` ✗ ${name}: ${err?.message || err}`); } async function loginAdmin(page) { const res = await page.request.post(`${API}/api/v1/auth/login`, { data: { username: 'admin', password: 'admin123' }, }); const body = await res.json(); await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); await page.evaluate(({ access, refresh, user }) => { localStorage.setItem('access_token', access); if (refresh) localStorage.setItem('refresh_token', refresh); if (user) localStorage.setItem('user', JSON.stringify(user)); }, { access: body?.tokens?.access, refresh: body?.tokens?.refresh, user: body?.user }); } async function main() { const browser = await chromium.launch({ headless: true }); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); const consoleErrors = []; page.on('console', (m) => { if (m.type() === 'error' && !/401|404|Failed to load|DevTools/.test(m.text())) { consoleErrors.push(m.text()); } }); console.log('\n════ Modal interaction regression ════'); await loginAdmin(page); // 找一个有参考素材的 completed record await page.goto(`${BASE}/admin/records`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1500); const completedRow = page.locator('tr').filter({ hasText: '已完成' }).first(); await completedRow.click({ force: true }); await page.waitForTimeout(1200); // 1. modal 打开 const modalOpen = await page.locator('text=任务详情').first().isVisible(); if (modalOpen) pass('1. 弹窗打开'); else { fail('1. 弹窗打开', new Error('找不到')); return; } // 2. 参考素材是否在(只测有 ref 的 record) const refSection = page.locator('text=参考素材').first(); const hasRefs = await refSection.isVisible().catch(() => false); if (!hasRefs) { pass('2. 参考素材区 (无 refs case,跳过后续 thumb 测试)'); await browser.close(); return; } pass('2. 参考素材区存在'); // 3. title 属性带完整 label const firstRefItem = page.locator('text=参考素材').locator('xpath=..').locator('div[title]').first(); const titleAttr = await firstRefItem.getAttribute('title').catch(() => null); if (titleAttr) pass(`3. thumb 有 title 属性 ("${titleAttr.slice(0, 30)}...")`); else fail('3. thumb title 缺失', new Error('hover tooltip 用的 title 属性没设上')); // 4. 点击 thumb img 弹 lightbox — thumb 是参考素材区内的 img 元素(ReferenceList refImgStyle 80×80) // lightbox 是 fixed full-screen img with max-width: 90vw const beforeClickImgs = await page.locator('img').count(); // 找参考素材区第一个 img(thumb,80×80) const thumbImg = page.locator('div[title]').locator('img').first(); const hasThumbImg = await thumbImg.isVisible().catch(() => false); if (hasThumbImg) { await thumbImg.click({ force: true }); await page.waitForTimeout(500); const afterClickImgs = await page.locator('img').count(); if (afterClickImgs > beforeClickImgs) { pass('4. 点击 thumb 弹出 lightbox (DOM 新增 img)'); // 4.1 验证 lightbox 关闭不连带关 modal — 用 Playwright 程序化 click on lightbox element // (不能用 mouse.click(x,y),因为 backdrop-filter 创建独立 stacking context, // lightbox overlay 的实际命中区域可能与视觉不一致) const lightboxOverlay = page.locator('div[style*="z-index"]').filter({ hasText: '' }).last(); // 简单粗暴:找最后一个 fixed inset 0 div(就是 lightbox overlay) const allOverlays = await page.locator('div').filter({ has: page.locator(':scope > img[style*="max-width: 90vw"]') }).all(); if (allOverlays.length > 0) { await allOverlays[0].click({ force: true, position: { x: 5, y: 5 } }); await page.waitForTimeout(400); } else { // 兜底:直接清 React state 通过 Esc 不太行,通过点击坐标 await page.mouse.click(20, 20); await page.waitForTimeout(400); } const modalStillOpenAfterLightboxClose = await page.locator('text=任务详情').first().isVisible().catch(() => false); if (modalStillOpenAfterLightboxClose) { pass('4.1 关闭 lightbox 不连带关 modal (stopPropagation 链 OK)'); } else { fail('4.1 lightbox 冒泡 bug', new Error('点 lightbox overlay 把 modal 一起关了 - 事件冒泡到 modal overlay')); } } else { fail('4. 点击 thumb 弹 lightbox', new Error(`img 数 ${beforeClickImgs} → ${afterClickImgs},没新增`)); } } else { pass('4. 点击 thumb 弹 lightbox (无 img thumb,可能都是 placeholder,跳过)'); } // 5. 下载按钮(stopPropagation,不应同时弹 lightbox) const downloadBtn = page.locator('button[title="下载"]').first(); const hasDownloadBtn = await downloadBtn.isVisible().catch(() => false); if (hasDownloadBtn) { // 监听 download 事件 — Playwright 拦截下载 const downloadPromise = page.waitForEvent('download', { timeout: 3000 }).catch(() => null); await downloadBtn.click({ force: true }); await page.waitForTimeout(500); const download = await downloadPromise; // lightbox 不应该被同时打开 const lightboxOpenAfterDl = await page.locator('div').filter({ has: page.locator('img[src][alt=""]') }).filter({ has: page.locator(':scope > img[style*="max-width: 90vw"]') }).count().catch(() => 0); if (lightboxOpenAfterDl === 0) { pass(`5. 下载按钮 stopPropagation 生效${download ? ' + 触发下载' : ''}`); } else { fail('5. stopPropagation', new Error('点下载按钮同时把 lightbox 也打开了')); } } else { pass('5. 下载按钮 (无 hasUrl 的 ref,跳过)'); } // 6. 关闭 modal await page.locator('button:has-text("✕")').first().click(); await page.waitForTimeout(400); const modalStillOpen = await page.locator('text=任务详情').first().isVisible().catch(() => false); if (!modalStillOpen) pass('6. modal 关闭 ✕ 按钮 OK'); else fail('6. modal 关闭', new Error('点 ✕ 后 modal 还在')); // 7. console 无 error if (consoleErrors.length === 0) pass('7. 全程无 console.error'); else fail('7. console errors', new Error(`${consoleErrors.length} 个:\n` + consoleErrors.slice(0, 3).join('\n'))); await browser.close(); const passCount = results.filter(r => r.ok).length; const failCount = results.filter(r => !r.ok).length; console.log(`\n══ Pass: ${passCount} / Fail: ${failCount} ══`); if (failCount > 0) { results.filter(r => !r.ok).forEach(r => console.log(` • ${r.name}: ${r.err}`)); process.exit(1); } } main().catch(e => { console.error(e); process.exit(1); });