#!/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 = `
pending