Regression / regression (push) Canceled after 0s
## 品牌标识(本次会话) 起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」, 那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」, 移动端站已是「K」;移动端文档站则完全没有 favicon)。 - 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边; 描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」 - 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark); 顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28)) - 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg) - 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留) - 移动端 7 个模板补 favicon(此前计数 0) 验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/ theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致; 两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。 ## 并行会话成果(本次一并入库) - 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具 + IconPreview 预览页 + ICON-SPEC.md 冻结规格 - 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁 - PC 组件:103 个大后台组件 / 组件11 批次 - uni-app:PC 端试点 + 移动端端实现 + 真实编译验证 ## 工程 - .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库) - CHANGELOG 补品牌标识条目 - ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
257 lines
12 KiB
JavaScript
257 lines
12 KiB
JavaScript
#!/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 — 全部通过`);
|