All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 11m2s
回归测试发现的真 bug (V1 V2 历史遗留):
点击 ReferenceList 的图片 lightbox 或音视频 player 的 overlay 关闭时,
事件冒泡 + V2 modal backdrop-filter 创建的独立 stacking context 双重作用
导致 RecordDetailModal 也被一起关掉。
根因双重:
1. lightbox/player overlay 的 onClick 没 stopPropagation,
React 合成事件冒泡到外层 modal overlay 触发 onClose
2. V2 modal inner 加了 backdrop-filter: blur 创建独立 stacking context,
lightbox 视觉上 z 10002 全屏覆盖,但实际命中区域被限制在 modal inner 内;
点击 lightbox 视觉外的暗色区域 (modal 外) 直接命中 modal overlay → onClose
修复 (ReferenceList.tsx):
- 用 React.createPortal 把 lightbox + audio-video player overlay 渲染到 document.body
脱离 modal inner 的 backdrop-filter stacking context,真正全屏
- overlay onClick 加 e.stopPropagation() 作为 React 事件链兜底
- playerWrap 内部 stopPropagation 保留(防止点 player 内部冒泡关闭 player)
新增测试 (web/test/modal-interaction.mjs):
专项验证 RecordDetailModal 内部交互, 7 个用例:
1. 任务详情弹窗打开 OK
2. 参考素材区存在 OK
3. thumb 有 title 属性 (V2 加的 tooltip 真生效)
4. 点击 thumb 弹出 lightbox (DOM 验证)
4.1 关闭 lightbox 不连带关 modal (本次 fix 的关键回归)
5. 下载按钮 stopPropagation + 触发下载
6. modal 关闭 ✕ 按钮 OK
7. 全程无 console.error
全套回归通过:
- TS 编译过
- modal-interaction 8/8 (含 lightbox 冒泡修复验证)
- v2-smoke 25/25 (无新挂)
- vitest 71/162 与基线一致 (无新挂)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
7.3 KiB
JavaScript
159 lines
7.3 KiB
JavaScript
/**
|
||
* 专项验证 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); });
|