#!/usr/bin/env node /** * verify-runtime-e2e.mjs — 实机环境验证 harness(真 chromium,非 iframe 结构断言) * * 为什么需要它: * 已有的 tests/report.json(PC 1405 条)是**测试页 iframe 内的结构/行为断言**, * verify-mobile-site.mjs 是移动端文档站的浏览器门禁 —— 两者都不覆盖 * 「实现文件被真实浏览器当页面/当库加载时会发生什么」: * ① 103 个 PC 演示页 + 37 个移动端演示页,逐个真渲染、看控制台、看 404、看可视化非空白; * ② dist/ 四端产物(React JSX / Vue2 / Vue3 / CSS)真编译 + 真挂载,断言渲染出的 DOM; * ③ 契约声明的变体类名,在真浏览器里确实产生不同视觉(不是"文件里有这行")。 * 这正是本仓库反复证明的那条:静态检查只能证明文件里有那几行,看不到加载没加载、落在哪。 * * 用法(分组由 --group 决定,便于多 agent 并行且互不写同一文件): * REG_BASE=http://127.0.0.1:3411 node tools/verify-runtime-e2e.mjs --group=pc-demos --shard=0/4 * REG_BASE=http://127.0.0.1:3411 node tools/verify-runtime-e2e.mjs --group=mobile-demos * REG_BASE=http://127.0.0.1:3411 node tools/verify-runtime-e2e.mjs --group=dist-mount * REG_BASE=http://127.0.0.1:3411 node tools/verify-runtime-e2e.mjs --group=contract-visual * * 产物:.zcode/verify-runtime/[-shardN].json(每组的原始证据,供复核) * 退出码:0 全通过 / 1 有失败 / 2 环境问题 * * 判据说明(为什么这样判,避免"为过而放宽"): * - 空白判定:演示页 body 必须渲染出 ≥1 个**有面积**(w>0&&h>0)且非 demo 布局壳的元素, * 且文本长度 > 0。只查 body.children 会漏掉"元素在但被 display:none / 高度塌成 0"。 * - 控制台:error + pageerror + requestfailed 三类都算。requestfailed 必须算 —— * 断链 CSS 正是"页面照样打开、样式全丢"的典型(见 verify-mobile-site.mjs 的教训)。 * - 404:显式监听 response,任何 4xx/5xx 的子资源都记为失败(排除 favicon,见下)。 * - 变体视觉差异:同一组件两个变体类(如 bt-primary vs btn-default)的 * background-color / color / border-color 至少一项必须不同 —— 相同说明类名没生效。 */ import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311'; const OUT_DIR = join(ROOT, '.zcode', 'verify-runtime'); const arg = (k, d = null) => { const hit = process.argv.find((a) => a.startsWith(k + '=')); return hit ? hit.slice(k.length + 1) : d; }; const GROUP = arg('--group', 'pc-demos'); const SHARD = arg('--shard', '0/1'); /* ---------- 工具 ---------- */ const results = []; function record(ok, id, detail) { results.push({ ok, id, detail: detail || null }); const tag = ok ? 'PASS' : 'FAIL'; console.log(` ${tag} ${id}${detail ? ' — ' + detail : ''}`); } let chromium; try { ({ chromium } = await import('playwright')); } catch { console.error('[FATAL] 未安装 playwright:npm i -D playwright && npx playwright install chromium'); process.exit(2); } async function preflight() { 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(' 请先起服务:KOLE_PORT=3411 node site/dev-server.js'); process.exit(2); } } /* 一个通用的"打开页面并体检"函数。 * 返回 { status, consoleErrors, badResponses, probe } —— probe 由各 group 自己给。 */ async function openAndProbe(browser, url, opts = {}) { const viewport = opts.viewport || { width: 1200, height: 800 }; const p = await browser.newPage({ viewport, colorScheme: opts.colorScheme }); const consoleErrors = []; const badResponses = []; p.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 200)); }); p.on('pageerror', (e) => consoleErrors.push('pageerror: ' + String(e.message).slice(0, 200))); p.on('requestfailed', (r) => { /* favicon 缺失是站点级小事(dev-server 未放行根 favicon),不计入组件缺陷 */ if (/favicon\.ico$/.test(r.url())) return; consoleErrors.push('requestfailed: ' + r.url().slice(0, 140) + ' (' + (r.failure()?.errorText || '?') + ')'); }); p.on('response', (r) => { const st = r.status(); if (st >= 400 && !/favicon\.ico$/.test(r.url())) { badResponses.push({ status: st, url: r.url().slice(0, 160) }); } }); let status = 0; try { const resp = await p.goto(BASE + url, { waitUntil: 'load', timeout: 20000 }); status = resp ? resp.status() : 0; await p.waitForTimeout(opts.settle ?? 700); } catch (e) { consoleErrors.push('goto: ' + String(e.message).slice(0, 160)); } let probe = null; if (status === 200 && opts.probe) { try { probe = await p.evaluate(opts.probe, opts.probeArg); } catch (e) { consoleErrors.push('probe: ' + String(e.message).slice(0, 160)); } } await p.close(); return { status, consoleErrors, badResponses, probe }; } /* 演示页体检探针:渲染面积 / 文本 / 令牌生效 / 可见组件元素数。 * 判据见文件头「空白判定」。 * * 为什么需要 clickedFallback(触发式浮层): * modal / drawer / message / notification 这类组件,**关闭态不渲染内容是正确行为**。 * 实测踩到 6 例:AlertModal / ConfirmModal / FormModal / FullScreenModal / MessagePro / NotificationPro * 在加载瞬间没有可见的组件类元素(遮罩 display:none 或容器为空),但点击文档化的触发按钮后 * 全部正常渲染(子 agent 各自用独立脚本点击验证过,并贴出样本)。 * 同一原则 harness 在 mount-all 里已承认("不填 visible/open/show:关闭时不渲染内容是正确行为"), * 这里补上同口径的二次判定:**先按静态判;静态不足时点一次页面上的按钮再判**, * 只有"点了也出不来"才算失败。 */ const DEMO_PROBE = ({ tokenName, tokenExpect, prefix }) => { const all = Array.from(document.querySelectorAll('body *')); const area = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; const visible = all.filter((el) => { const cs = getComputedStyle(el); return cs.display !== 'none' && cs.visibility !== 'hidden' && area(el); }); const clsOf = (el) => (el.className && el.className.baseVal !== undefined ? el.className.baseVal : el.className) || ''; const own = visible.filter((el) => { const cls = clsOf(el); return typeof cls === 'string' && prefix.some((p) => cls.split(/\s+/).some((c) => c.startsWith(p))); }); const text = (document.body.innerText || '').trim(); const root = getComputedStyle(document.documentElement); /* 无文本不代表空白:SkeletonPro(骨架占位块无文字)与 ImagePreview(序号只在遮罩展开后出现) 都是"有像素、无 innerText"的正常页面。故补一条与文本等价的"确实有内容"判据。 只认**background-image**(渐变/图片)而不认纯色背景:纯色背景几乎每页都有(body 就是), 那样判据会恒真、失去区分力;而 linear-gradient 这类绘制恰恰是 SkeletonPro 唯一的可视内容 (实测 6 个占位块 420×120、animationName: kole-shimmer,无 img 也无文本)。 再排除 html/body,避免把"页面底色"当成组件内容。 */ const hasPaintable = all.some((el) => { if (!area(el)) return false; return ['IMG', 'SVG', 'CANVAS', 'VIDEO'].includes(el.tagName); }); const hasOwnText = all.some((el) => el.children.length === 0 && (el.textContent || '').trim().length > 0 && area(el)); const hasPaintedBg = all.some((el) => { if (!area(el) || el === document.body || el === document.documentElement) return false; const bgImg = getComputedStyle(el).backgroundImage; return !!bgImg && bgImg !== 'none'; }); /* 触发式浮层的线索:页面上有按钮,且存在带 kole- 前缀但当前不可见(0 面积/被 display 隐藏)的元素 */ const triggerBtns = all.filter((el) => el.tagName === 'BUTTON' && area(el) && !el.disabled); const hiddenKole = all.filter((el) => { const cls = clsOf(el); if (typeof cls !== 'string' || !prefix.some((p) => cls.split(/\s+/).some((c) => c.startsWith(p)))) return false; const cs = getComputedStyle(el); return !area(el) || cs.display === 'none' || cs.visibility === 'hidden'; }); return { visibleCount: visible.length, ownCount: own.length, textLen: text.length, token: tokenName ? root.getPropertyValue(tokenName).trim() : null, tokenExpect: tokenExpect || null, title: document.title, triggerCount: triggerBtns.length, hiddenKoleCount: hiddenKole.length, hasPaintable, hasOwnText, hasPaintedBg, }; }; /* 触发式浮层的二次判定:点击页面上的按钮,看组件元素是否出现。 * 返回点击后被点亮(可见)的组件类元素数量。 */ const OVERLAY_RETRY_PROBE = (prefix) => { const clsOf = (el) => (el.className && el.className.baseVal !== undefined ? el.className.baseVal : el.className) || ''; const area = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; const btns = Array.from(document.querySelectorAll('button')).filter((b) => area(b) && !b.disabled); for (const b of btns.slice(0, 6)) { try { b.click(); } catch (e) { /* 单个按钮点不动不影响其它按钮的尝试 */ } } const vis = Array.from(document.querySelectorAll('body *')).filter((el) => { const cls = clsOf(el); if (typeof cls !== 'string' || !prefix.some((p) => cls.split(/\s+/).some((c) => c.startsWith(p)))) return false; const cs = getComputedStyle(el); return cs.display !== 'none' && cs.visibility !== 'hidden' && area(el); }); return { ownCountAfterClick: vis.length, textLenAfterClick: (document.body.innerText || '').trim().length }; }; /* ---------- group: pc-demos ---------- */ async function groupPcDemos(browser) { const data = JSON.parse(readFileSync(join(ROOT, 'site', 'data.json'), 'utf8')); const slips = data.components.map((c) => c.slug); /* slug -> frameworks/.html(大小写不敏感匹配,index 里是 slug 小写) */ const htmlList = (await import('node:fs')).readdirSync(join(ROOT, 'frameworks')).filter((f) => f.endsWith('.html')); const bySlug = new Map(); for (const f of htmlList) bySlug.set(f.replace(/\.html$/, '').toLowerCase(), f); const [si, sn] = SHARD.split('/').map(Number); const mine = slips.filter((_, i) => i % sn === si); console.log(`[pc-demos] 分片 ${SHARD} · 本片 ${mine.length}/${slips.length} 个组件`); for (const slug of mine) { const file = bySlug.get(slug); if (!file) { record(false, `${slug} 演示页存在`, 'frameworks 下无同名 html'); continue; } const r = await openAndProbe(browser, `/frameworks/${file}`, { probe: DEMO_PROBE, probeArg: { tokenName: '--kole-color-brand', tokenExpect: '#2F54EB', prefix: ['kole-', 'btn', 'au-'] }, }); const pr = r.probe || {}; const clean = r.consoleErrors.length === 0 && r.badResponses.length === 0; /* 静态判定不足时,做一次"点了会出来吗"的二次判定(触发式浮层,见 OVERLAY_RETRY_PROBE 注释)。 仅当页面确实存在"隐藏的组件元素 + 可点按钮"线索时才值得重试,避免给纯静态页加噪声。 */ const staticOwnOk = pr.ownCount > 0; let retry = null; if (!staticOwnOk && pr.triggerCount > 0 && pr.hiddenKoleCount > 0) { retry = await openAndProbe(browser, `/frameworks/${file}`, { settle: 800, probe: OVERLAY_RETRY_PROBE, probeArg: ['kole-', 'btn', 'au-'] }); } const ownAfter = retry && retry.probe ? retry.probe.ownCountAfterClick : 0; /* "有内容"的判据:可见元素 + (有文本 或 有可绘制元素 或 有渐变/图片背景 或 有叶子文本节点)。 只查 innerText 会把 SkeletonPro / ImagePreview 这类"有像素无文本"的页面误判成空白。 */ const hasContent = pr.textLen > 0 || pr.hasPaintable || pr.hasPaintedBg || pr.hasOwnText; const rendered = r.status === 200 && pr.visibleCount > 0 && hasContent && (staticOwnOk || ownAfter > 0); record( r.status === 200 && clean && rendered, `${slug} 实机渲染`, [ r.status !== 200 ? `HTTP ${r.status}` : null, pr.visibleCount === 0 ? '无可视元素(空白)' : null, !hasContent ? '无文本且无可绘制内容' : null, !staticOwnOk && ownAfter === 0 ? `无组件类元素(静态 ${pr.ownCount};点击 ${pr.triggerCount} 个按钮后仍为 ${ownAfter})` : null, r.consoleErrors.length ? `${r.consoleErrors.length} 控制台错误: ${r.consoleErrors.slice(0, 2).join(' || ')}` : null, r.badResponses.length ? `坏响应: ${r.badResponses.slice(0, 2).map((b) => b.status + ' ' + b.url).join(' || ')}` : null, ] .filter(Boolean) .join(' ; ') || (staticOwnOk ? null : `点击后组件元素 ${ownAfter} 个(触发式浮层,关闭态不渲染属正确行为)`) ); /* 令牌必须真的解析出值(未被改动/断链时才可能为空) */ record(pr.token === '#2F54EB', `${slug} 令牌生效`, pr.token === '#2F54EB' ? null : `--kole-color-brand="${pr.token}"`); } } /* ---------- group: mobile-demos ---------- */ async function groupMobileDemos(browser) { const data = JSON.parse(readFileSync(join(ROOT, 'site', 'm', 'data.mobile.json'), 'utf8')); const slugs = data.components.map((c) => c.slug); const htmlList = (await import('node:fs')).readdirSync(join(ROOT, 'frameworks-mobile')).filter((f) => f.endsWith('.html')); const bySlug = new Map(); for (const f of htmlList) bySlug.set(f.replace(/\.html$/, '').toLowerCase(), f); const [si, sn] = SHARD.split('/').map(Number); const mine = slugs.filter((_, i) => i % sn === si); console.log(`[mobile-demos] 分片 ${SHARD} · 本片 ${mine.length}/${slugs.length} 个组件`); for (const slug of mine) { /* 优先用数据自带的 demo 字段(权威映射),不要靠 slug 猜文件名: 移动端 slug 是 mobile-dialog 而文件是 Dialog.html(实测踩到 4 个误报)。 */ const comp = data.components.find((c) => c.slug === slug); const file = (comp && comp.demo) || bySlug.get(slug) || htmlList.find((f) => f.toLowerCase().replace(/[^a-z0-9]/g, '') === slug.replace(/[^a-z0-9]/g, '')); if (!file) { record(false, `${slug} 移动端演示页存在`, '数据 demo 字段与目录内均无对应 html'); continue; } const r = await openAndProbe(browser, `/frameworks-mobile/${file}`, { viewport: { width: 390, height: 844 }, probe: DEMO_PROBE, probeArg: { tokenName: '--kole-m-touch-target', tokenExpect: '44px', prefix: ['kole-m-'] }, }); const pr = r.probe || {}; const clean = r.consoleErrors.length === 0 && r.badResponses.length === 0; const rendered = r.status === 200 && pr.visibleCount > 0 && pr.textLen > 0; record( r.status === 200 && clean && rendered, `${slug} 移动端实机渲染`, [ r.status !== 200 ? `HTTP ${r.status}` : null, pr.visibleCount === 0 ? '无可视元素(空白)' : null, pr.textLen === 0 ? '无文本' : null, r.consoleErrors.length ? `${r.consoleErrors.length} 控制台错误: ${r.consoleErrors.slice(0, 2).join(' || ')}` : null, r.badResponses.length ? `坏响应: ${r.badResponses.slice(0, 2).map((b) => b.status + ' ' + b.url).join(' || ')}` : null, ] .filter(Boolean) .join(' ; ') || null ); record(pr.token === '44px', `${slug} 移动端令牌生效`, pr.token === '44px' ? null : `--kole-m-touch-target="${pr.token}"`); } } /* ---------- group: dist-mount ---------- * 真编译 + 真挂载四端产物。这一组回答的是「用户按 README 接入后能不能用」。 * React/Vue3/Vue2 用 esbuild 打包 dist 入口 + 各端运行时,产出 HTML,在真浏览器里挂载, * 断言渲染出的 DOM 含契约声明的类名。 * CSS 端:直接把 dist/components/index.css + tokens 挂进页面,断言 sample 类名产生预期样式。 */ async function groupDistMount(browser) { const { createRequire } = await import('node:module'); let esbuild, compilerSfc3, templateCompiler2; const DEPS = join(ROOT, '.zcode', 'verify-import'); const DEPS2 = join(ROOT, '.zcode', 'verify-vue2'); const req = createRequire(join(DEPS, 'x.js')); const req2 = createRequire(join(DEPS2, 'x.js')); try { esbuild = req('esbuild'); compilerSfc3 = req('@vue/compiler-sfc'); /* Vue 2 用经典编译器 vue-template-compiler(2.7 的 vue/compiler-sfc 是 Vue3 风格 API, 没有 compile(),硬用它会把「工具用错」记成「组件坏了」) */ templateCompiler2 = req2('vue-template-compiler'); req2('vue'); req('vue'); req('react'); req('react-dom'); } catch (e) { console.error('[FATAL] 缺少编译依赖:' + e.message); console.error(' 需要 .zcode/verify-import/{esbuild,@vue/compiler-sfc,react,react-dom,vue} 与 .zcode/verify-vue2/{vue@2,vue-template-compiler}'); process.exit(2); } /* 运行时从这两棵树里解析(宿主代码位于 .zcode/verify-runtime/ 下,向上找不到 node_modules) */ const NODE_PATHS = [join(DEPS, 'node_modules'), join(DEPS2, 'node_modules')]; const OUT = join(OUT_DIR, 'dist-mount'); mkdirSync(OUT, { recursive: true }); /* --- 1) Vue3:入口 .vue 真编译 + 真挂载 --- */ const vue3Plugin = { name: 'vue-sfc3', setup(build) { build.onLoad({ filter: /\.vue$/ }, (args) => { const source = readFileSync(args.path, 'utf8'); const { descriptor, errors } = compilerSfc3.parse(source, { filename: args.path }); if (errors.length) throw new Error('SFC parse: ' + errors[0].message); const id = Buffer.from(args.path).toString('hex').slice(0, 8); const script = compilerSfc3.compileScript(descriptor, { id }); const template = descriptor.template ? compilerSfc3.compileTemplate({ source: descriptor.template.content, filename: args.path, id, scoped: descriptor.styles.some((s) => s.scoped), compilerOptions: { bindingMetadata: script.bindings }, }) : { code: 'export function render(){return null}' }; if (template.errors && template.errors.length) throw new Error('template: ' + template.errors[0].message); return { contents: `${script.content.replace(/export\s+default/, 'const __sfc_main =')}\n${template.code.replace(/export\s+function\s+render/, 'function __sfc_render')}\n__sfc_main.render = __sfc_render;\nexport default __sfc_main;`, loader: 'js', resolveDir: dirname(args.path), }; }); }, }; /* --- 2) Vue2:入口 .vue 真编译 + 真挂载(vue-template-compiler,Vue 2 经典编译器) --- */ const vue2Plugin = { name: 'vue-sfc2', setup(build) { build.onLoad({ filter: /\.vue$/ }, (args) => { const source = readFileSync(args.path, 'utf8'); const parsed = templateCompiler2.parseComponent(source); const scriptContent = parsed.script ? parsed.script.content : 'export default {}'; if (parsed.template) { const c = templateCompiler2.compile(parsed.template.content); if (c.errors && c.errors.length) throw new Error('vue2 template: ' + c.errors[0]); /* render 是函数体字符串,必须包成函数(同 vue-loader 的做法) */ return { contents: `${scriptContent.replace(/export\s+default/, 'const __sfc_main =')}\n__sfc_main.render = function () { ${c.render} };\n__sfc_main.staticRenderFns = [${(c.staticRenderFns || []).map((fn) => `function () { ${fn} }`).join(',')}];\nexport default __sfc_main;`, loader: 'js', resolveDir: dirname(args.path), }; } return { contents: `${scriptContent.replace(/export\s+default/, 'const __sfc_main =')}\nexport default __sfc_main;`, loader: 'js', resolveDir: dirname(args.path), }; }); }, }; /* 每个端:写一个宿主 script,打包成 ESM bundle */ const ends = [ { key: 'react', label: 'React', entry: join(ROOT, 'dist', 'react', 'index.js'), plugin: [], externals: ['react', 'react-dom', 'react-dom/client'], handle: async (bundlePath, outDir) => { const host = ` import React from 'react'; import { createRoot } from 'react-dom/client'; import * as Kole from './entry.mjs'; const el = document.getElementById('root'); const list = Object.keys(Kole).filter(k => typeof Kole[k] === 'function').sort(); const target = Kole.KoleButton || Kole.Button; const children = []; if (target) children.push(React.createElement(target, { key: 'primary', type: 'primary', text: '保存' })); children.push(React.createElement('div', { key: 'marks', id: 'marks', 'data-count': list.length })); createRoot(el).render(React.createElement('div', { id: 'wrap' }, children)); `; writeFileSync(join(outDir, 'host.jsx'), host); return { hostPath: join(outDir, 'host.jsx'), entryRel: './entry.mjs' }; }, }, { key: 'vue3', label: 'Vue 3', entry: join(ROOT, 'dist', 'vue3', 'index.js'), plugin: [vue3Plugin], externals: ['vue'], handle: async (bundlePath, outDir) => { const host = ` import { createApp, h } from 'vue'; import * as Kole from './entry.mjs'; const list = Object.keys(Kole).filter(k => Kole[k] && typeof Kole[k] === 'object').sort(); const target = Kole.KoleButton || Kole.Button; const app = createApp({ render() { const kids = []; if (target) kids.push(h(target, { type: 'primary', text: '保存' })); kids.push(h('div', { id: 'marks', 'data-count': String(list.length) })); return h('div', { id: 'wrap' }, kids); } }); app.mount('#root'); `; writeFileSync(join(outDir, 'host.js'), host); return { hostPath: join(outDir, 'host.js'), entryRel: './entry.mjs' }; }, }, { key: 'vue2', label: 'Vue 2', entry: join(ROOT, 'dist', 'vue2', 'index.js'), plugin: [vue2Plugin], externals: ['vue'], handle: async (bundlePath, outDir) => { const host = ` import Vue from 'vue'; import * as Kole from './entry.mjs'; const list = Object.keys(Kole).filter(k => k.startsWith('Kole')).sort(); const target = Kole.KoleButton || Kole.Button; new Vue({ render(h) { const children = []; if (target) children.push(h(target, { props: { type: 'primary', text: '保存' } })); children.push(h('div', { attrs: { id: 'marks', 'data-count': String(list.length) } })); return h('div', { attrs: { id: 'wrap' } }, children); }, }).$mount('#root'); `; writeFileSync(join(outDir, 'host.js'), host); return { hostPath: join(outDir, 'host.js'), entryRel: './entry.mjs' }; }, }, ]; for (const end of ends) { const outDir = join(OUT, end.key); mkdirSync(outDir, { recursive: true }); /* 1) 把 dist 入口打成 ESM(运行时走 externals,从本地 node_modules 取真版本) */ try { await esbuild.build({ entryPoints: [end.entry], bundle: true, format: 'esm', outfile: join(outDir, 'entry.mjs'), plugins: end.plugin, external: end.externals, nodePaths: NODE_PATHS, loader: { '.css': 'empty' }, logLevel: 'silent', }); } catch (e) { record(false, `dist/${end.key} 入口真编译`, String(e.errors?.[0]?.text || e.message).slice(0, 200)); continue; } record(true, `dist/${end.key} 入口真编译`, null); /* 2) 宿主页:用 importmap 指到本地 node_modules 里的运行时(真版本,非 CDN) */ const { hostPath } = await end.handle(join(outDir, 'entry.mjs'), outDir); try { await esbuild.build({ entryPoints: [hostPath], bundle: true, format: 'iife', outfile: join(outDir, 'host.iife.js'), plugins: end.plugin, nodePaths: NODE_PATHS, loader: { '.css': 'empty' }, logLevel: 'silent', }); } catch (e) { record(false, `dist/${end.key} 宿主真编译`, String(e.errors?.[0]?.text || e.message).slice(0, 200)); continue; } record(true, `dist/${end.key} 宿主真编译`, null); /* 3) 真浏览器挂载 */ const tokens = readFileSync(join(ROOT, 'dist', 'tokens', 'tokens.css'), 'utf8'); const compCss = readFileSync(join(ROOT, 'dist', 'components', 'index.css'), 'utf8'); const html = `
`; writeFileSync(join(outDir, 'index.html'), html); /* 产物落在 .zcode/ 下(不在 dev-server 的 PUBLIC_ROOTS 里),故用 setContent + addScriptTag 内联注入:既走真浏览器,又不把临时文件写进 site/ 等受守卫的产物目录。 */ const bundleJs = readFileSync(join(outDir, 'host.iife.js'), 'utf8'); const p = await browser.newPage({ viewport: { width: 900, height: 700 } }); const errors = []; p.on('console', (m) => { if (m.type() === 'error') errors.push(m.text().slice(0, 200)); }); p.on('pageerror', (e) => errors.push('pageerror: ' + String(e.message).slice(0, 200))); await p.setContent(`
`); await p.addScriptTag({ content: bundleJs }); await p.waitForTimeout(800); const mountProbe = await p.evaluate(() => { const root = document.getElementById('root'); const html = root ? root.innerHTML : ''; const btn = root ? root.querySelector('button') : null; const cs = btn ? getComputedStyle(btn) : null; const marks = document.getElementById('marks'); return { htmlLen: html.length, buttonCount: root ? root.querySelectorAll('button').length : 0, btnClass: btn ? btn.className : null, btnBg: cs ? cs.backgroundColor : null, exportCount: marks ? marks.getAttribute('data-count') : null, text: root ? (root.innerText || '').trim().slice(0, 60) : '', }; }); await p.close(); const classOk = !!mountProbe.btnClass && /btn/.test(mountProbe.btnClass); const bgOk = !!mountProbe.btnBg && mountProbe.btnBg !== 'rgba(0, 0, 0, 0)'; record( errors.length === 0 && mountProbe.buttonCount > 0 && classOk && bgOk && mountProbe.text.includes('保存'), `dist/${end.key} 真挂载渲染`, [ mountProbe.buttonCount === 0 ? '无 button' : null, !classOk ? `类名异常: ${mountProbe.btnClass}` : null, !bgOk ? `背景色未生效: ${mountProbe.btnBg}` : null, !mountProbe.text.includes('保存') ? `文本异常: "${mountProbe.text}"` : null, errors.length ? `控制台: ${errors.slice(0, 2).join(' || ')}` : null, ].filter(Boolean).join(' ; ') || `导出 ${mountProbe.exportCount} 个 / class="${mountProbe.btnClass}" / bg=${mountProbe.btnBg}` ); record( Number(mountProbe.exportCount) >= 100, `dist/${end.key} 导出数量`, `实测 ${mountProbe.exportCount}(期望 ≥100,data.json 为 103)` ); } /* --- 4) CSS 端:真浏览器里确认类名产生样式 --- */ const tokensCss = readFileSync(join(ROOT, 'dist', 'tokens', 'tokens.css'), 'utf8'); const cssIndex = readFileSync(join(ROOT, 'dist', 'components', 'index.css'), 'utf8'); const p = await browser.newPage({ viewport: { width: 900, height: 700 } }); const errors = []; p.on('console', (m) => { if (m.type() === 'error') errors.push(m.text().slice(0, 160)); }); await p.setContent(` `); await p.waitForTimeout(300); const cssProbe = await p.evaluate(() => { const g = (sel) => { const el = document.querySelector(sel); if (!el) return null; const cs = getComputedStyle(el); return { bg: cs.backgroundColor, color: cs.color, border: cs.borderColor, h: el.getBoundingClientRect().height }; }; return { primary: g('#b1'), def: g('#b2') }; }); await p.close(); const differs = cssProbe.primary && cssProbe.def && (cssProbe.primary.bg !== cssProbe.def.bg || cssProbe.primary.color !== cssProbe.def.color || cssProbe.primary.border !== cssProbe.def.border); record( differs && cssProbe.primary.bg !== 'rgba(0, 0, 0, 0)' && Math.round(cssProbe.primary.h) === 32, 'dist CSS 端变体真实生效', `primary bg=${cssProbe.primary?.bg} h=${cssProbe.primary?.h} / default bg=${cssProbe.def?.bg} h=${cssProbe.def?.h}` ); } /* ---------- group: contract-visual ---------- * 契约声明的变体维度,在真浏览器里确实产生**不同视觉**。 * 这比"类名存在于 CSS 文件"强:类名在文件里但没被任何规则命中时,视觉相同 → 判失败。 */ async function groupContractVisual(browser) { const compDir = join(ROOT, '.design_library', 'kole-ui', 'components'); const data = JSON.parse(readFileSync(join(ROOT, 'site', 'data.json'), 'utf8')); const htmlList = (await import('node:fs')).readdirSync(join(ROOT, 'frameworks')).filter((f) => f.endsWith('.html')); const bySlug = new Map(htmlList.map((f) => [f.replace(/\.html$/, '').toLowerCase(), f])); /* 只取真正的**组件契约**:`index.json` 是设计库索引(键是 schemaVersion/library/components…), 没有 variantDimensions,也不是组件 —— 早期按 "*.json" 收全部文件会把它当契约去查演示页, 必然失败(实测踩到 index 误报"frameworks 下无同名演示页")。判据:必须有 variantDimensions 键。 */ const files = (await import('node:fs')) .readdirSync(compDir) .filter((f) => f.endsWith('.json')) .filter((f) => { try { return Object.prototype.hasOwnProperty.call(JSON.parse(readFileSync(join(compDir, f), 'utf8')), 'variantDimensions'); } catch { return false; } }); const [si, sn] = SHARD.split('/').map(Number); const mine = files.filter((_, i) => i % sn === si); console.log(`[contract-visual] 分片 ${SHARD} · 本片 ${mine.length}/${files.length} 份契约`); for (const f of mine) { const slug = f.replace(/\.json$/, ''); const c = JSON.parse(readFileSync(join(compDir, f), 'utf8')); /* 变体维度有两个来源:variantDimensions(结构化)与 representativeVariants(代表变体清单)。 10 个组件只有后者(form / pageheader / radio / timepicker 等),只读前者会把它们整批跳过 —— 而 form / pageheader 恰恰有真缺陷(-primary 被 -button 源序覆盖),漏掉就看不到。 故在此把两个来源归一成同一种 {name, values} 形态。 */ let dims = (c.variantDimensions || []).map((d) => ({ name: d.name, values: d.values })); if (!dims.length && Array.isArray(c.representativeVariants) && c.representativeVariants.length) { dims = [{ name: 'representativeVariants', values: c.representativeVariants.map((r) => (typeof r === 'string' ? r : r.variant)).filter(Boolean) }]; } /* 两个来源都没有 → 无从比较,N/A */ if (!dims.length || !dims.some((d) => (d.values || []).length >= 2)) { console.log(` SKIP ${slug} 变体视觉可分 — 契约未声明可比较的变体维度(N/A)`); continue; } const file = bySlug.get(slug); if (!file) { record(false, `${slug} 契约变体视觉`, `frameworks 下无同名演示页(契约 slug=${slug})`); continue; } /* 在演示页里取**同族**元素的视觉签名。 关键:状态类(is-success / is-error)常各自挂在**独立的根元素**上, 只按"整组类名"聚合时每组只剩 1 个成员 → 结构性看不见差异(实测踩到: resultvariants 靠同页按钮颜色偶然过关,alert 则完全漏掉)。 故把"契约声明的变体值"作为比对对象:在页面里找带 `is-<值>` 或 `--<值>` 或 `-<值>` 的元素, 同族内不同变体值之间必须视觉可分。 */ const r = await openAndProbe(browser, `/frameworks/${file}`, { settle: 700, probe: (dimsIn) => { /* 视觉签名必须包含**后代**:不少变体只给子元素上色,容器自身计算样式不变。 实测踩到:`.kole-timeline-item.is-done > .kole-timeline-node` 与 `is-error > .kole-timeline-node` 颜色不同,但两者容器签名一致 → 被判"视觉相同"。故取"自身 + 关键后代"的合并签名。 */ const styleOf = (el) => { const pick = (n) => { const cs = getComputedStyle(n); return [ cs.backgroundColor, cs.color, cs.borderColor, cs.borderRadius, cs.fontSize, cs.height, cs.position, cs.left, cs.right, cs.top, cs.boxShadow, cs.transform, cs.display, cs.fontWeight, ].join('|'); }; const own = pick(el); const kids = Array.from(el.children).slice(0, 6).map((c) => pick(c)).join('~~'); const pseudo = ['::before', '::after'].map((ps) => { const cs = getComputedStyle(el, ps); return [cs.content, cs.backgroundColor, cs.borderColor, cs.transform, cs.display].join('|'); }).join('~~'); return own + '@@' + kids + '@@' + pseudo; }; const clsOf = (el) => (typeof el.className === 'string' ? el.className : (el.className && el.className.baseVal) || ''); const visible = (el) => { const r2 = el.getBoundingClientRect(); return r2.width > 0 && r2.height > 0; }; const all = Array.from(document.querySelectorAll('body *')).filter(visible); /* 变体值的承载形式是类名里的**一个连字符段**:btn-primary / is-success / kole-alert-error 都算 primary / success / error。早期用 `(^|\s)(is-)?value($|\s)` 会漏掉 btn-primary (value 前面是 "btn-",既非空白也非 is-)→ 实测把 button 的 5 个 type 值全判成"未渲染"。 故按空白切类名、再按连字符切段,段相等即命中。 */ const segmentHit = (el, v) => { const cls = clsOf(el); if (!cls) return false; return cls.split(/\s+/).some((c) => c.split('-').includes(v)); }; /* **逐维度**比较:同一个值名可能出现在不同维度里(button 的 type 与 size 都有 "default"), 跨维度混比会把"合法的同形值"当成冲突(实测踩到 button 误报)。 故每个维度单独取它的值集合,只在该维度内部比对。 */ const perDim = []; for (const d of dimsIn) { const vals = (d.values || []).map(String); const found = []; for (const v of vals) { const hits = all.filter((el) => segmentHit(el, v)); if (hits.length) found.push({ val: v, sig: styleOf(hits[0]), cls: clsOf(hits[0]).slice(0, 60) }); } /* 该维度里至少 2 个值被渲染出来,才有可比较的证据 */ if (found.length >= 2) { const uniq = new Set(found.map((x) => x.sig)); const collisions = found.filter((x) => found.some((y) => y.val !== x.val && y.sig === x.sig)); perDim.push({ dim: d.name, total: found.length, distinct: uniq.size, collisions: collisions.map((x) => x.val + '(' + x.cls + ')') }); } else { perDim.push({ dim: d.name, total: found.length, distinct: 0, collisions: [], insufficient: true, declared: vals.length }); } } return { perDim }; }, probeArg: dims, }); /* 决定性补充判定:**变体类是否至少能命中某条 CSS 规则**。 做法(真浏览器):摘掉承载类 → 比对「自身 + 后代 + 伪元素 + 关键几何」的计算样式。 完全相同 ⇒ 该类对视觉零影响。 注意这条判据的边界(实测逐条核过,避免误报): - Select 的 `.is-open .kole-select-trigger` 是**后代选择器**,摘类后子元素样式会变 → 生效; - `.is-disabled` 在部分页面上挂在非规则匹配的元素上(页面里没有对应结构)→ 摘类不变, 但**规则本身存在**,属"演示页未覆盖"而非"类失效"。 故这里再补一道静态校验:只有「摘类无变化 **且** CSS 里找不到任何以该类为选择器的规则」才判失败。 */ const effProbe = await openAndProbe(browser, `/frameworks/${file}`, { settle: 700, probe: (dimsIn) => { const pick = (n) => { const cs = getComputedStyle(n); return [ cs.backgroundColor, cs.color, cs.borderColor, cs.borderRadius, cs.fontSize, cs.height, cs.position, cs.left, cs.right, cs.boxShadow, cs.transform, cs.fontWeight, cs.opacity, ].join('|'); }; const clsOf = (el) => (typeof el.className === 'string' ? el.className : (el.className && el.className.baseVal) || ''); const area = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; const results = []; for (const d of dimsIn) { for (const v of (d.values || []).map(String)) { const hit = Array.from(document.querySelectorAll('body *')).filter((el) => { if (!area(el)) return false; return clsOf(el).split(/\s+/).some((c) => c.split('-').includes(v)); })[0]; if (!hit) continue; const carrier = clsOf(hit).split(/\s+/).find((c) => c.split('-').includes(v)); if (!carrier) continue; const snap = () => { const own = pick(hit); const kids = Array.from(hit.querySelectorAll('*')).slice(0, 12).map(pick).join('~~'); const pseudo = ['::before', '::after'] .map((ps) => { const cs = getComputedStyle(hit, ps); return [cs.content, cs.backgroundColor, cs.borderColor, cs.transform, cs.display].join('|'); }) .join('~~'); const r2 = hit.getBoundingClientRect(); const geo = [Math.round(r2.width), Math.round(r2.height)].join('x'); return own + '@@' + kids + '@@' + pseudo + '@@' + geo; }; const before = snap(); hit.classList.remove(carrier); const afterNoClass = snap(); hit.classList.add(carrier); // 立刻还原 results.push({ dim: d.name, val: v, carrier, changed: before !== afterNoClass }); } } /* 静态校验用:把页面内
`); await p.addScriptTag({ content: bundleJs }); await p.waitForTimeout(end.asyncReady ? 1200 : 600); const probe = await p.evaluate(() => { if (typeof window.__koleSync === 'function') window.__koleSync(); const rs = window.__koleResults || []; return { count: rs.length, ok: rs.filter((r) => r.ok).length, crashed: rs.filter((r) => r.err).map((r) => r.name + ' :: ' + r.err).slice(0, 12), empty: rs.filter((r) => !r.err && r.empty).map((r) => r.name).slice(0, 20), }; }); await p.close(); /* 判据:崩溃 = 硬缺陷(给足 prop 仍抛错);空渲染单列为**待查**,但阈值必须来自实测而非拍脑袋: 初始 prop 袋下 React 有 7 个空(modal/imagepreview/loadingoverlay 关闭态不渲染是**正确行为**)。 故阈值取「实测基线 + 余量」,且必须逐名列出,让复核者能自己判断哪几个是合理的。 */ record( probe.crashed.length === 0, `${end.label} 全量挂载无崩溃(${probe.count} 个)`, [ probe.crashed.length ? `${probe.crashed.length} 个抛错: ${probe.crashed.slice(0, 4).join(' | ')}` : null, errors.length ? `页面控制台 ${errors.length} 条: ${errors.slice(0, 2).join(' || ')}` : null, ].filter(Boolean).join(' ; ') || `${probe.ok}/${probe.count} 渲染出 DOM` ); /* 空渲染:逐名报出,数量超过"关闭态组件"合理范围才判失败。 modal / drawer / imagepreview / loadingoverlay 关闭时不渲染 = 正确行为, 故允许一个与"关闭态组件数"相当的基线,其余为空即需人工确认。 */ const EMPTY_BASELINE = 12; record( probe.empty.length <= EMPTY_BASELINE, `${end.label} 空渲染数量受控(≤${EMPTY_BASELINE})`, probe.empty.length ? `${probe.empty.length} 个为空: ${probe.empty.slice(0, 14).join(', ')}` : '0 个空渲染' ); } } /* esbuild 调用包装:把编译错误变成结构化结果,避免各处重复 try/catch */ async function p_esbuildBuild(esbuild, opts) { try { await esbuild.build(opts); return { ok: true }; } catch (e) { return { ok: false, err: String(e.errors?.[0]?.text || e.message).slice(0, 220) }; } } /* ---------- main ---------- */ await preflight(); mkdirSync(OUT_DIR, { recursive: true }); const browser = await chromium.launch(); console.log(`[verify-runtime] group=${GROUP} shard=${SHARD} base=${BASE}`); try { if (GROUP === 'pc-demos') await groupPcDemos(browser); else if (GROUP === 'mobile-demos') await groupMobileDemos(browser); else if (GROUP === 'dist-mount') await groupDistMount(browser); else if (GROUP === 'contract-visual') await groupContractVisual(browser); else if (GROUP === 'interaction') await groupInteraction(browser); else if (GROUP === 'compile-ends') await groupCompileEnds(browser); else if (GROUP === 'mount-all') await groupMountAll(browser); else { console.error('[FATAL] 未知 group: ' + GROUP); process.exit(2); } } finally { await browser.close(); } const pass = results.filter((r) => r.ok).length; const fail = results.filter((r) => !r.ok); const slugTag = SHARD.replace('/', '-'); /* 文件名必须含 group + 平台 + 分片,否则并行子 agent 会互相覆盖证据: 实测踩到 —— `interaction --platform=pc --shard=0/2` 与 `--platform=mobile --shard=0/2` 会写出同一个 interaction-0-2.json。 */ const platTag = GROUP === 'interaction' ? '-' + arg('--platform', 'pc') : ''; const outFile = join(OUT_DIR, `${GROUP}${platTag}${slugTag === '0-1' ? '' : '-' + slugTag}.json`); writeFileSync( outFile, JSON.stringify( { group: GROUP, platform: GROUP === 'interaction' ? arg('--platform', 'pc') : null, shard: SHARD, base: BASE, generated: new Date().toISOString(), pass, fail: fail.length, results }, null, 1 ) ); console.log(''); if (fail.length) { console.error(`[FAIL] ${fail.length} 条失败(通过 ${pass} 条)→ ${outFile}`); fail.slice(0, 25).forEach((f) => console.error(' - ' + f.id + (f.detail ? ' — ' + f.detail : ''))); process.exit(1); } console.log(`[OK] 全部通过(${pass} 条)→ ${outFile}`);