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 未做部分)
410 lines
22 KiB
JavaScript
410 lines
22 KiB
JavaScript
#!/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);
|