#!/usr/bin/env node /** * verify-stage-overlay.mjs — 演示帧内「覆盖型浮层」必须铺满真实视口 * * 前置:仓库根目录起 dev-server(node site/dev-server.js) * 依赖:npm i -D playwright(CI 专用;运行时零依赖不变) * 运行:node tools/verify-stage-overlay.mjs * 退出码:0 全通过 / 1 有断言失败 / 2 环境问题 * * 为什么需要它(2026-09-20 实测的缺陷): * 文档站把演示页放进按内容高度撑开的 iframe(.ex-stage / .demo-stage + fitFrame), * 帧视口 == 演示框尺寸。组件里 position:fixed; inset:0 的覆盖层以**帧视口**为包含块, * 于是「点击图片全屏」「打开对话框」只在演示框内铺开 —— imagepreview 实测遮罩 * 724×134,宿主视口 1241×1233。这是承载容器的形态问题(演示页单独打开时覆盖层是铺满的), * 修法在站点侧:帧内出现覆盖层时把演示舞台升到视图层(site/app.js 的 watchStageLift)。 * * 断言分两组: * A(正向)逐组件:点开触发 → 帧内覆盖层尺寸必须≈宿主视口(容差 2px),且演示舞台带 * .is-lifted、宿主滚动被锁;关闭 → 提升撤销、滚动锁解除、帧回到内容高度。 * B(反向)不该提升的必须不提升:水印全屏层(装饰,不是浮层)、BackToTop(贴右下角)、 * AnchorNav(贴右侧)、TabBar(贴底部)、局部 LoadingOverlay(absolute 局部块)、 * 以及没有任何浮层的普通演示页(如 button)—— 它们都不能被提升,否则「全屏」会把 * 一个贴边浮层错放大成整屏。 * * 判据只认浏览器实测的几何量,不做静态推断:静态检查看不出 position:fixed 的包含块是谁。 */ import { chromium } from 'playwright'; const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311'; const TOL = 2; // 与视口的像素容差 const failures = []; function assert(ok, msg) { if (!ok) failures.push(msg); console.log(`[stage-overlay] ${ok ? 'OK ' : 'FAIL'} ${msg}`); } /* 正向用例:slug / 进入浮层的帧内操作 / 关闭浮层的帧内操作 / 期望的浮层选择器 */ const POSITIVE = [ { slug: 'imagepreview', layer: '.kole-imgprev-mask', open: (d) => d.querySelector('.kole-thumb').click(), close: (d) => d.getElementById('close').click() }, { slug: 'modal', layer: '.kole-mask', /* 同上:这个场景的预览计划把挂载点 #mount 隐藏了(预览只展示三个按钮), 遮罩虽被创建却整块不可见。还原成演示页原样后再点。 */ prep: (d) => { d.querySelectorAll('[style*="display: none"]').forEach((n) => { n.style.display = ''; }); }, open: (d) => d.querySelector('.btn-primary').click(), close: (d) => { const m = d.querySelector('.kole-mask'); if (m) m.click(); } }, { slug: 'alertmodal', layer: '.kole-modal-mask', open: (d) => d.getElementById('open').click(), close: (d) => d.getElementById('ok').click() }, { slug: 'confirmmodal', layer: '.kole-modal-mask', open: (d) => d.getElementById('open').click(), close: (d) => d.getElementById('cancel').click() }, { slug: 'formmodal', layer: '.kole-formmodal-mask', open: (d) => d.getElementById('open').click(), close: (d) => d.getElementById('cancel').click() }, { slug: 'fullscreenmodal', layer: '.kole-fs-mask', open: (d) => d.getElementById('open').click(), close: (d) => d.getElementById('cancel').click() }, { slug: 'bottomsheet', layer: '.kole-sheet-mask', /* 文档站给这个组件配的逐示例预览计划会隐藏遮罩与触发按钮 (它展示的是面板底部两个按钮,不是「打开面板」这个动作)。 要测「遮罩出现时必须提升」,先把预览计划隐藏掉的节点还原成演示页原样。 */ prep: (d) => { d.querySelectorAll('[style*="display: none"]').forEach((n) => { n.style.display = ''; }); }, open: (d) => d.getElementById('open').click(), close: (d) => d.getElementById('cancel').click() }, { slug: 'loadingoverlay', layer: '.kole-loading-overlay.is-dark', open: (d) => { const btns = d.querySelectorAll('button'); for (const b of btns) { if (/全屏/.test(b.textContent)) { b.click(); return; } } }, close: (d) => { const btns = d.querySelectorAll('button'); for (const b of btns) { if (/全屏/.test(b.textContent)) { b.click(); return; } } } } ]; /* 反向用例:进入一个"看着像但没有覆盖视口"的状态,或干脆不触发 */ const NEGATIVE = [ { slug: 'watermark', why: '水印全屏层是装饰,不是浮层' }, { slug: 'backtotop', why: '贴右下角的浮动按钮不提升' }, { slug: 'anchornav', why: '贴右侧的目录不提升' }, { slug: 'button', why: '无任何覆盖层的普通页不提升' }, { slug: 'messagepro', why: '顶部消息条不提升(页面加载即无浮层)' }, /* 移动端站是**静态站**,不加载 site/app.js —— 这条断的是「PC 的提升逻辑不得泄漏到移动端」, 不是「移动端浮层不该提升」(那边压根没有提升机制)。移动端演示框用 transform 建立包含块 把面板限定在框内,是有意的设计(见 AGENTS.md 已知陷阱表),本方案不动它。 */ { slug: 'tabbar', why: 'PC 提升逻辑不泄漏到移动端静态站', mobile: true } ]; let chromium_; try { ({ chromium: chromium_ } = await import('playwright')); } catch { console.error('[FATAL] 未安装 playwright。CI 环境请先运行:npm i -D playwright && npx playwright install --with-deps chromium'); process.exit(2); } /* 环境自检 */ try { const r = await fetch(`${BASE}/site/data.json`, { signal: AbortSignal.timeout(5000) }); if (!r.ok) throw new Error(`HTTP ${r.status}`); } catch (e) { console.error(`[FATAL] 无法连接 ${BASE}/site/data.json — ${e.message}`); console.error(' 请先在仓库根目录运行:node site/dev-server.js'); process.exit(2); } const inPage = () => { const f = document.querySelector('.ex-stage iframe, .demo-stage iframe'); if (!f) return { error: 'no-frame' }; const stage = f.parentElement; return { lifted: !!stage && stage.classList.contains('is-lifted'), locked: document.documentElement.classList.contains('kole-stage-locked'), stagePos: stage ? getComputedStyle(stage).position : '', frame: { w: Math.round(f.getBoundingClientRect().width), h: Math.round(f.getBoundingClientRect().height) }, viewport: { w: document.documentElement.clientWidth, h: document.documentElement.clientHeight } }; }; const layerRectIn = (sel) => { const f = document.querySelector('.ex-stage iframe, .demo-stage iframe'); if (!f || !f.contentDocument) return null; const n = f.contentDocument.querySelector(sel); if (!n) return null; const r = n.getBoundingClientRect(); const cs = f.contentDocument.defaultView.getComputedStyle(n); return { w: Math.round(r.width), h: Math.round(r.height), shown: cs.display !== 'none' && cs.visibility !== 'hidden' && parseFloat(cs.opacity) > 0 }; }; const browser = await chromium_.launch(); const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); try { /* ---------- A 组:正向 ---------- */ for (const t of POSITIVE) { const url = `${BASE}/site/component/${t.slug}`; try { await page.goto(url, { waitUntil: 'load', timeout: 20000 }); await page.waitForSelector('.ex-stage iframe, .demo-stage iframe', { timeout: 10000 }); await page.waitForTimeout(600); /* 需要时先还原预览计划隐藏的节点(见用例里的 prep 注释) */ if (t.prep) { await page.evaluate(({ slug }) => { const f = document.querySelector('.ex-stage iframe, .demo-stage iframe'); const d = f && f.contentDocument; if (d) d.querySelectorAll('[style*="display: none"]').forEach((n) => { n.style.display = ''; }); }, { slug: t.slug }); await page.waitForTimeout(200); } /* 帧内点击触发 */ const opened = await page.evaluate(({ slug }) => { const f = document.querySelector('.ex-stage iframe, .demo-stage iframe'); const d = f && f.contentDocument; if (!d) return 'no-doc'; const ops = { imagepreview: () => d.querySelector('.kole-thumb').click(), modal: () => d.querySelector('.btn-primary').click(), alertmodal: () => d.getElementById('open').click(), confirmmodal: () => d.getElementById('open').click(), formmodal: () => d.getElementById('open').click(), fullscreenmodal: () => d.getElementById('open').click(), bottomsheet: () => d.getElementById('open').click(), loadingoverlay: () => { for (const b of d.querySelectorAll('button')) if (/全屏/.test(b.textContent)) { b.click(); return; } } }; try { ops[slug](); return 'ok'; } catch (e) { return 'err:' + e.message; } }, { slug: t.slug }); if (opened !== 'ok') { assert(false, `${t.slug}:帧内触发浮层(${opened})`); continue; } await page.waitForTimeout(500); /* 等 MutationObserver + 过渡 */ const st = await page.evaluate(inPage); const rect = await page.evaluate(layerRectIn, t.layer); if (!rect) { assert(false, `${t.slug}:帧内未找到浮层 ${t.layer}`); continue; } assert(rect.shown, `${t.slug}:浮层 ${t.layer} 处于显示态`); assert(Math.abs(rect.w - st.viewport.w) <= TOL && Math.abs(rect.h - st.viewport.h) <= TOL, `${t.slug}:浮层铺满真实视口(浮层 ${rect.w}×${rect.h} vs 视口 ${st.viewport.w}×${st.viewport.h})`); assert(st.lifted, `${t.slug}:演示舞台被提升(.is-lifted)`); assert(st.locked, `${t.slug}:宿主滚动已锁(html.kole-stage-locked)`); /* 关闭 → 复位 */ const closed = await page.evaluate(({ slug }) => { const f = document.querySelector('.ex-stage iframe, .demo-stage iframe'); const d = f.contentDocument; const ops = { imagepreview: () => d.getElementById('close').click(), modal: () => { const m = d.querySelector('.kole-mask'); if (m) m.click(); }, alertmodal: () => d.getElementById('ok').click(), confirmmodal: () => d.getElementById('cancel').click(), formmodal: () => d.getElementById('cancel').click(), fullscreenmodal: () => d.getElementById('cancel').click(), bottomsheet: () => d.getElementById('cancel').click(), loadingoverlay: () => { for (const b of d.querySelectorAll('button')) if (/全屏/.test(b.textContent)) { b.click(); return; } } }; try { ops[slug](); return 'ok'; } catch (e) { return 'err:' + e.message; } }, { slug: t.slug }); await page.waitForTimeout(500); const st2 = await page.evaluate(inPage); if (closed === 'ok') { assert(!st2.lifted, `${t.slug}:关闭浮层后撤销提升`); assert(!st2.locked, `${t.slug}:关闭浮层后解除滚动锁`); assert(st2.stagePos !== 'fixed', `${t.slug}:演示舞台回到常规文档流(position=${st2.stagePos})`); assert(st2.frame.h < st2.viewport.h, `${t.slug}:帧回到内容高度(${st2.frame.h} < 视口 ${st2.viewport.h})`); } } catch (e) { assert(false, `${t.slug}:用例执行异常 — ${e.message}`); } } /* ---------- B 组:反向(不该提升的必须不提升) ---------- */ for (const t of NEGATIVE) { const prefix = t.mobile ? '/site/m/component/' : '/site/component/'; try { await page.goto(`${BASE}${prefix}${t.slug}`, { waitUntil: 'load', timeout: 20000 }); await page.waitForTimeout(900); const probe = await page.evaluate(() => { const frames = Array.from(document.querySelectorAll('iframe')); return { lifted: Array.from(document.querySelectorAll('.is-lifted')).map((n) => n.className), locked: document.documentElement.classList.contains('kole-stage-locked'), frames: frames.length }; }); assert(probe.lifted.length === 0, `${t.slug}:不被提升(${t.why})— 实际 ${JSON.stringify(probe.lifted)}`); assert(!probe.locked, `${t.slug}:不触发滚动锁`); } catch (e) { assert(false, `${t.slug}(反向):用例执行异常 — ${e.message}`); } } } finally { await browser.close(); } if (failures.length) { console.error(`\n[stage-overlay] ${failures.length} check(s) failed`); process.exit(1); } console.log(`\n[stage-overlay] OK — 全部通过`);