Files
aurora-admin/tools/verify-playground.mjs
T
aurora-admin f1fbfc2ddb
Regression / regression (push) Canceled after 0s
feat(品牌标识): 几何 K 图标(favicon/顶栏标记/theme-color) + 并行会话成果入库
## 品牌标识(本次会话)

起因:品牌此前没有任何图形标识 —— 唯一 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 未做部分)
2026-09-21 10:05:48 +08:00

410 lines
22 KiB
JavaScript
Raw 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.
#!/usr/bin/env node
/**
* verify-playground.mjs — 「在线测试」页(site/playground.html)的浏览器级验收
*
* 前置:仓库根目录起 dev-server(node site/dev-server.js),或把 REG_BASE 指向已跑的实例。
* 依赖:npm i -D playwright(CI 专用;运行时零依赖不变)
* 运行:node tools/verify-playground.mjs
* 退出码:0 全通过 / 1 有断言失败 / 2 环境问题
*
* 为什么需要它:这是站上唯一一处把「用户随手编辑的 HTML」灌进 iframe 跑的地方,
* 而它的两半都只能在真浏览器里证明 ——
* 安全那半:沙箱边界是运行期行为(不透明源 / sandbox 权限 / 两条 CSP 取交集),
* 读代码只能看到属性字符串,看不到"到底拦没拦住";本脚本用**内嵌探针服务器**
* 的命中数当判据(数为 0 才算拦住),而不是读帧内的报错文本。
* 功能那半:预览帧的资源基准由 srcdoc 的继承规则决定 —— 演示源码里的 "./Xxx.css"
* 会解析到 /site/ 而不是 /frameworks/(实测 79 个组件里 36 个命中,
* 预览因此丢组件样式),只有真跑一遍看请求落到哪个 URL 才发现得了。
*
* 断言:
* A 资源解析(全 79 组件):预览帧渲染出内容;源码里带 ./ 引用的组件,其组件 CSS
* 确实从 /frameworks/ 取到且 200;全程 0 个 4xx(favicon 除外)。
* B 沙箱边界:帧是不透明源(parent / top / cookie / localStorage 全 SecurityError,
* opener 为 null);外联(fetch / beacon / WebSocket / 图片 / 外域样式 / 子帧 /
* 表单 / 弹窗 / 顶层跳转)全部发不出去 —— 判据是探针服务器 0 命中。
* C 交互:脏状态切组件要确认(取消则原地不动);目标组件源码读不到时不改状态、
* 不清空编辑器;帧内自己跳走会被重建;帧内脚本报错经 postMessage 显示在预览上方。
*/
import http from 'node:http';
import { readFileSync } from 'node:fs';
const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311';
const PLAYGROUND = `${BASE}/site/playground.html`;
const failures = [];
function assert(ok, msg) {
if (!ok) failures.push(msg);
console.log(`[playground] ${ok ? 'OK ' : 'FAIL'} ${msg}`);
}
let chromium;
try {
({ chromium } = await import('playwright'));
} catch {
console.error('[playground] 未安装 playwright。CI 环境请先 npm i -D playwright && npx playwright install --with-deps chromium');
process.exit(2);
}
/* 期望组件数取自设计库索引(与 pack-deploy / 各门禁同一来源):
组件数随规格批次增长,写死 79 会在每次扩批后假失败。 */
const PC_INDEX_PATH = new URL('../.design_library/kole-ui/components/index.json', import.meta.url);
let pcExpected = 0;
try {
pcExpected = JSON.parse(readFileSync(PC_INDEX_PATH, 'utf8')).components.length;
} catch (e) {
console.error('[playground] 读不到设计库索引:' + e.message);
process.exit(2);
}
let data;
try {
/* 预检超时按远程部署留足:data.json 实测 1.36 MB,经 CDN 首拉可能十几秒 */
const r = await fetch(`${BASE}/site/data.json`, { signal: AbortSignal.timeout(30000) });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
data = await r.json();
} catch (e) {
console.error(`[playground] 无法连接 ${BASE}/site/data.json — ${e.message}`);
console.error(' 本地:先在仓库根目录运行 node site/dev-server.js(REG_BASE 可指向任意实例,含线上)');
process.exit(2);
}
const comps = data.components || [];
/* 组件的演示文件路径一律取自 data.json(大小写各组件不同:Card.html / button.html),
本脚本任何地方都不写死路径 —— 写死的小写路径在 Windows dev-server 上能过,
到 Linux 容器(区分大小写)就是 404,属只在真部署上才暴露的假绿(实测踩过)。 */
const compBySlug = Object.fromEntries(comps.map((c) => [c.slug, c]));
const fileOf = (slug) => BASE + '/' + String(compBySlug[slug].files.html).replace(/^(\.\.\/)+/, '');
/* 探针服务器:帧内发起的所有请求都会落在这里。0 命中 = 一条都没发出去。 */
const hits = [];
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==', 'base64');
const probe = http.createServer((req, res) => {
hits.push(`${req.method} ${req.url}`);
if (/\.png/.test(req.url)) { res.writeHead(200, { 'Content-Type': 'image/png' }); res.end(PNG); return; }
res.writeHead(200, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
res.end('ok');
});
await new Promise((r) => probe.listen(0, '127.0.0.1', r));
const PP = probe.address().port;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1500, height: 950 } });
/* 远程部署(REG_BASE 指线上)下每次往返都带公网延迟,超时留足 */
page.setDefaultTimeout(30000);
const badStatus = [];
const badConsole = [];
page.on('response', (r) => {
if (r.status() >= 400 && !/favicon/.test(r.url())) badStatus.push(`${r.status()} ${r.url().replace(BASE, '')}`);
});
page.on('console', (m) => { if (m.type() === 'error') badConsole.push(m.text().slice(0, 160)); });
/* 对话框统一在这里落地:confirm 是"切组件会丢改动"的拦截,beforeunload 是"离开页面会丢"的拦截 */
const dialogs = [];
let dialogPolicy = 'dismiss';
page.on('dialog', async (d) => {
dialogs.push(d.type());
if (dialogPolicy === 'accept') await d.accept(); else await d.dismiss();
});
const childFrame = () => page.mainFrame().childFrames()[0];
const statusText = () => page.evaluate(() => document.getElementById('pg-status').textContent);
const editorValue = () => page.evaluate(() => document.getElementById('pg-editor').value);
const selectValue = () => page.evaluate(() => document.getElementById('pg-select').value);
const textOf = (sel) => page.evaluate((s) => { const el = document.querySelector(s); return el && !el.classList.contains('hidden') ? el.textContent : ''; }, sel);
/* 灌源码:走真实输入路径(input 事件 → 300ms 防抖 → srcdoc) */
async function setSource(html) {
const f = childFrame();
const nav = page.waitForEvent('framenavigated', (x) => x === f, { timeout: 30000 });
await page.evaluate((h) => {
const ta = document.getElementById('pg-editor');
ta.value = h;
ta.dispatchEvent(new Event('input', { bubbles: true }));
}, html);
await nav;
await f.waitForFunction(() => document.readyState === 'complete', null, { timeout: 30000 });
}
/* 等"新文档真的建好了"再读帧:只等状态行会读到上一个文档(实测踩过 —— 3 个组件因此假失败) */
async function waitFrame() {
const f = childFrame();
await f.waitForFunction(() => document.readyState === 'complete', null, { timeout: 30000 });
return f;
}
async function switchTo(slug) {
const f = childFrame();
const nav = page.waitForEvent('framenavigated', (x) => x === f, { timeout: 30000 });
await page.evaluate((s) => {
const el = document.getElementById('pg-select');
el.value = s;
el.dispatchEvent(new Event('change'));
}, slug);
await nav;
await f.waitForFunction(() => document.readyState === 'complete', null, { timeout: 30000 });
return f;
}
async function openSlug(slug) {
dialogPolicy = 'accept'; /* 上一段可能留下未复位改动:离开时的确认框一律放行 */
await page.goto(`${PLAYGROUND}?slug=${slug}`, { waitUntil: 'load' });
dialogPolicy = 'dismiss';
await page.waitForFunction(() => /^已同步/.test(document.getElementById('pg-status').textContent), null, { timeout: 30000 });
await waitFrame();
}
/* 帧内自己跳走之后:等它回到 srcdoc 并且新文档跑完(导航期间读帧会撞 Execution context destroyed) */
async function waitResetFrame(marker) {
for (let i = 0; i < 50; i++) {
try {
const f = childFrame();
if (f && f.url() === 'about:srcdoc') {
const st = await f.evaluate(() => document.readyState + '|' + ((document.getElementById('o') || {}).textContent || ''));
if (st.startsWith('complete') && st.endsWith(marker)) return true;
}
} catch (e) { /* 导航中,重试 */ }
await page.waitForTimeout(150);
}
return false;
}
try {
/* ---------- A · 资源解析(全量组件) ---------- */
await openSlug('button');
const withRel = [];
for (const c of comps) {
const src = (c.sources && c.sources.html) || '';
const rel = [...src.matchAll(/href="\.\/([^"]+\.css)"/g)].map((m) => m[1]);
withRel.push({ slug: c.slug, rel });
}
const relTotal = withRel.filter((x) => x.rel.length).length;
let rendered = 0;
const missing = [];
for (const c of comps) {
const f = await switchTo(c.slug);
const info = await f.evaluate(() => ({
kids: document.body ? document.body.children.length : 0,
sheets: Array.from(document.styleSheets).map((s) => s.href).filter(Boolean)
})).catch(() => null);
if (!info) { missing.push(`${c.slug} 帧不可读`); continue; }
if (info.kids > 0) rendered++;
const rel = (withRel.find((x) => x.slug === c.slug) || {}).rel || [];
for (const file of rel) {
if (!info.sheets.some((h) => h.endsWith('/frameworks/' + file))) {
missing.push(`${c.slug} 组件 CSS 未按 /frameworks/${file} 加载(实际 ${info.sheets.join(' | ') || '无'})`);
}
}
}
assert(comps.length === pcExpected, `组件清单取到 ${comps.length} 个(期望 ${pcExpected},取自设计库索引)`);
assert(rendered === comps.length, `每个组件的预览帧都渲染出内容(${rendered}/${comps.length})`);
assert(relTotal > 0, `存在带 ./ 相对引用的组件(${relTotal} 个,回归哨兵的样本量)`);
assert(missing.length === 0, `带 ./ 引用的组件 CSS 全部从 /frameworks/ 取到${missing.length ? ' —— ' + missing.slice(0, 3).join(';') : ''}`);
/* ---------- B · 沙箱边界 ---------- */
hits.length = 0;
const payload = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>probe</title>
<link rel="stylesheet" href="http://127.0.0.1:${PP}/ext.css">
<style>@import url("http://127.0.0.1:${PP}/import.css");</style>
</head><body><pre id="o">pending</pre>
<script>
var R=[];
function t(n,f){try{R.push(n+'='+String(f()).slice(0,70));}catch(e){R.push(n+'=THROW:'+e.name);}}
t('origin',function(){return location.origin;});
t('parentDoc',function(){return parent.document.title;});
t('topDoc',function(){return top.document.title;});
t('cookie',function(){return JSON.stringify(document.cookie);});
t('localStorage',function(){return localStorage.length;});
t('opener',function(){return String(window.opener);});
t('topNav',function(){top.location.href='http://127.0.0.1:${PP}/topnav';return 'attempted';});
t('popup',function(){return String(window.open('http://127.0.0.1:${PP}/popup'));});
var i=new Image();i.src='http://127.0.0.1:${PP}/e.png';
fetch('http://127.0.0.1:${PP}/fetch',{method:'POST',body:'x',mode:'no-cors'});
navigator.sendBeacon('http://127.0.0.1:${PP}/beacon','x');
try{new WebSocket('ws://127.0.0.1:${PP}/ws');R.push('ws=created');}catch(e){R.push('ws=THROW:'+e.name);}
var fr=document.createElement('iframe');fr.src='http://127.0.0.1:${PP}/child';document.body.appendChild(fr);
var fm=document.createElement('form');fm.action='http://127.0.0.1:${PP}/form';fm.method='POST';fm.target='_blank';
document.body.appendChild(fm);fm.submit();R.push('form=submitted');
t('metaCsp',function(){return document.querySelector('meta[http-equiv="Content-Security-Policy"]').content;});
t('baseHref',function(){return document.baseURI;});
setTimeout(function(){document.getElementById('o').textContent=R.join(' | ');},500);
<\/script></body></html>`;
await setSource(payload);
await page.waitForTimeout(2200);
const f = childFrame();
const got = f ? await f.evaluate(() => (document.getElementById('o') || {}).textContent || '') : '';
const val = (k) => (String(got).split(' | ').find((x) => x.startsWith(k + '=')) || '').slice(k.length + 1);
assert(val('origin') === 'null', `预览帧是不透明源(location.origin=${val('origin') || '未取到'})`);
for (const k of ['parentDoc', 'topDoc', 'cookie', 'localStorage', 'topNav']) {
assert(val(k) === 'THROW:SecurityError', `帧内 ${k} 被拒(${val(k) || '未取到'})`);
}
assert(val('opener') === 'null', `帧内 window.opener 为空(${val('opener') || '未取到'})`);
assert(val('popup') === 'null', `帧内 window.open 被拦(${val('popup') || '未取到'})`);
assert(val('metaCsp').includes("connect-src 'none'") && val('metaCsp').includes("form-action 'none'"), '帧内注入了收紧用的 meta CSP');
assert(/\/frameworks\/$/.test(val('baseHref')), `帧内基准 URL 指向 /frameworks/(${val('baseHref') || '未取到'})`);
const blocked = badConsole.filter((m) => /Content Security Policy|sandbox|Refused to|Unsafe/i.test(m));
assert(blocked.length >= 3, `拦截有策略层证据(命中 ${blocked.length} 条 CSP/sandbox 报错)`);
await page.waitForTimeout(1200);
assert(hits.length === 0, `探针服务器 0 命中(实际 ${hits.length} 条${hits.length ? ':' + hits.slice(0, 4).join(', ') : ''})`);
/* 带未保存修改时离开页面:弹 beforeunload —— 取消则留在本页且改动还在,接受才走 */
badStatus.length = 0;
const dlgBefore = dialogs.length;
const urlBefore = page.url();
dialogPolicy = 'dismiss';
await page.goto(`${PLAYGROUND}?slug=card`, { waitUntil: 'load' }).catch(() => { /* 取消导航属预期 */ });
assert(dialogs.slice(dlgBefore).includes('beforeunload'), `脏状态离开页面触发 beforeunload(新增 ${dialogs.length - dlgBefore} 个)`);
assert(page.url() === urlBefore, `取消提示后留在原页(${page.url().split('/').pop()})`);
assert((await editorValue()).includes('probe'), '取消提示后编辑器里的改动还在');
dialogPolicy = 'accept';
await page.goto(`${PLAYGROUND}?slug=button`, { waitUntil: 'load' });
assert(/playground\.html/.test(page.url()), '接受提示后可正常离开页面');
dialogPolicy = 'dismiss';
/* ---------- C · 交互 ---------- */
await openSlug('button');
const dlgClean = dialogs.length;
await openSlug('card');
assert(dialogs.length === dlgClean, '干净状态下切页/导航不弹任何对话框(守卫不误伤)');
await openSlug('button');
const beforeCancel = await editorValue();
await page.evaluate(() => {
const ta = document.getElementById('pg-editor');
ta.value = '<!-- user edit -->';
ta.dispatchEvent(new Event('input', { bubbles: true }));
});
const dlgCancel = dialogs.length;
dialogPolicy = 'dismiss';
await page.evaluate(() => {
const s = document.getElementById('pg-select');
s.value = 'card';
s.dispatchEvent(new Event('change'));
});
await page.waitForTimeout(400);
assert(dialogs.length === dlgCancel + 1 && dialogs[dialogs.length - 1] === 'confirm',
`脏状态切组件弹出确认框(新增 ${dialogs.length - dlgCancel} 个 ${dialogs[dialogs.length - 1] || ''})`);
assert((await editorValue()) === '<!-- user edit -->', '取消切换后编辑器内容原样保留');
assert((await selectValue()) === 'button', `取消切换后选择器回到原组件(${await selectValue()})`);
dialogPolicy = 'accept';
await switchTo('card');
assert((await selectValue()) === 'card', '确认后切到目标组件');
assert((await editorValue()) !== '<!-- user edit -->' && (await editorValue()).length > 100, '确认后编辑器换成新组件源码');
assert(beforeCancel !== (await editorValue()), '切换确实换了源码(基线对照)');
/* 目标组件源码读不到:不改状态、不清空编辑器、给出错误提示 */
await page.route('**/data.json', (route) => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
components: [
{ slug: 'button', name: '按钮', files: { html: compBySlug.button.files.html }, sources: { html: '<!DOCTYPE html><html><body><p id="ok">OK</p></body></html>' } },
{ slug: 'zz-broken', name: '坏组件', files: { html: '/site/definitely-missing.html' } }
]
})
}));
await openSlug('button');
await page.evaluate(() => {
const ta = document.getElementById('pg-editor');
ta.value = '<!-- 改了一半 -->';
ta.dispatchEvent(new Event('input', { bubbles: true }));
});
dialogPolicy = 'accept';
await page.evaluate(() => {
const s = document.getElementById('pg-select');
s.value = 'zz-broken';
s.dispatchEvent(new Event('change'));
});
await page.waitForFunction(() => document.getElementById('pg-status').textContent === '加载失败', null, { timeout: 30000 });
assert((await editorValue()) === '<!-- 改了一半 -->', '源码拉取失败时编辑器内容不被清空');
assert((await selectValue()) === 'button', `源码拉取失败时选择器回退到原组件(${await selectValue()})`);
assert((await textOf('#pg-error')).includes('读取 H5 源码失败'), '源码拉取失败时给出错误提示');
await page.unroute('**/data.json');
/* 刚才是故意让它 404 的,别把这次计入"全程 0 个 4xx" */
badStatus.length = 0;
/* 回落成 fetch 的异步路径:先发的慢请求不得覆盖后选的组件源码(乱序返回) */
const slowFile = fileOf('card');
const cardSrc = await (await fetch(slowFile)).text();
await page.route('**/data.json', (route) => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
components: [
{ slug: 'slow-a', name: '慢 A', files: { html: compBySlug.card.files.html } },
{ slug: 'fast-b', name: '快 B', files: { html: compBySlug.button.files.html } }
]
})
}));
await page.route(slowFile, async (route) => {
await new Promise((r) => setTimeout(r, 700));
await route.continue();
});
dialogPolicy = 'accept';
await page.goto(`${PLAYGROUND}?slug=slow-a`, { waitUntil: 'load' });
dialogPolicy = 'dismiss';
await page.waitForTimeout(150);
await page.evaluate(() => {
const s = document.getElementById('pg-select');
s.value = 'fast-b';
s.dispatchEvent(new Event('change'));
});
await page.waitForTimeout(1800);
const buttonSrc = await (await fetch(fileOf('button'))).text();
assert((await editorValue()) === buttonSrc, `先发后至的慢请求被丢弃(编辑器应是 fast-b 的源码,实际 ${(await editorValue()).slice(0, 24).replace(/\n/g, ' ')}…)`);
assert((await selectValue()) === 'fast-b', `乱序后选择器仍指向最新选择(${await selectValue()})`);
await page.unroute('**/data.json');
await page.unroute(slowFile);
badStatus.length = 0;
/* 帧内自己跳走:父页感知后重建预览 */
await openSlug('button');
await setSource(`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><p id="o">v1</p>
<script>
if (window.name !== 'nudged') { window.name = 'nudged'; setTimeout(function(){ location.href = 'http://127.0.0.1:${PP}/nudge'; }, 500); }
else { document.getElementById('o').textContent = 'v2'; }
<\/script></body></html>`);
await page.waitForFunction(() => /已重置预览内的跳转/.test(document.getElementById('pg-status').textContent), null, { timeout: 30000 });
const recovered = await waitResetFrame('v2');
assert(recovered && childFrame().url() === 'about:srcdoc', `帧内跳转被重建回 srcdoc(${childFrame() ? childFrame().url() : '无帧'})`);
assert(recovered, '重建后帧内脚本从头跑一遍(预览恢复可用)');
assert(hits.filter((h) => /nudge/.test(h)).length === 0, '帧内自跳转同样出不去(探针 0 命中)');
/* 帧内报错回传:预览不再静默白屏 */
await openSlug('button');
await setSource('<!DOCTYPE html><html><body><script>throw new Error("boom-from-preview");<\/script></body></html>');
await page.waitForTimeout(600);
assert((await textOf('#pg-note')).includes('boom-from-preview'), `帧内脚本报错显示在预览上方(${(await textOf('#pg-note')).slice(0, 60)})`);
assert((await childFrame().evaluate(() => document.body.children.length)) >= 1, '报错后预览帧仍在本页(未被报错带走)');
/* 暗色跟随:站点暗色时预览帧也反色 */
const darkPage = await browser.newPage({ viewport: { width: 1500, height: 950 } });
await darkPage.addInitScript(() => localStorage.setItem('kole-mode', 'dark'));
await darkPage.goto(`${PLAYGROUND}?slug=button`, { waitUntil: 'load' });
await darkPage.waitForFunction(() => /^已同步/.test(document.getElementById('pg-status').textContent), null, { timeout: 30000 });
const darkFrame = darkPage.mainFrame().childFrames()[0];
await darkFrame.waitForFunction(() => document.readyState === 'complete', null, { timeout: 30000 });
/* 令牌样式表是外链:公网下比文档慢,等它真生效再读 —— 否则读到空串会误判成"没反色"(本地有缓存,踩不到) */
await darkFrame.waitForFunction(() => getComputedStyle(document.documentElement).getPropertyValue('--kole-color-page-bg').trim() !== '', null, { timeout: 30000 });
const dark = await darkFrame.evaluate(() => ({
cls: document.documentElement.className,
pageBg: getComputedStyle(document.documentElement).getPropertyValue('--kole-color-page-bg').trim()
}));
assert(/\bkole-dark\b/.test(dark.cls), `站点暗色时预览帧带 kole-dark(class="${dark.cls}")`);
assert(dark.pageBg.toUpperCase() === '#14161C', `预览帧取到暗色令牌(--kole-color-page-bg=${dark.pageBg})`);
await darkPage.close();
assert(badStatus.length === 0, `全程 0 个 4xx(favicon 除外)${badStatus.length ? ' —— ' + badStatus.slice(0, 3).join(';') : ''}`);
} catch (e) {
failures.push('中断: ' + String(e && e.message ? e.message : e).split('\n')[0].slice(0, 200));
console.error('[playground] FAIL 中断: ' + String(e && e.message ? e.message : e).split('\n')[0].slice(0, 200));
} finally {
await browser.close();
await new Promise((r) => probe.close(r));
}
console.log(`\n[playground] ${failures.length === 0 ? 'OK — all checks passed' : `FAIL — ${failures.length} 项未通过`}`);
process.exit(failures.length === 0 ? 0 : 1);