video-shuoshan/web/test/modal-interaction.mjs
seaislee1209 6d683d4e76
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 11m2s
fix(records): lightbox/player overlay 关闭误触外层 modal — Portal + stopPropagation
回归测试发现的真 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>
2026-05-12 16:31:02 +08:00

159 lines
7.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 专项验证 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); });