Files
aurora-admin/tools/verify-runtime-e2e.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

1631 lines
83 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-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/<group>[-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/<Name>.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 = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>${tokens}\n${compCss}</style></head>
<body><div id="root"></div><script src="host.iife.js"></script></body></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(`<!DOCTYPE html><html><head><meta charset="utf-8"><style>${tokens}\n${compCss}</style></head><body><div id="root"></div></body></html>`);
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(`<!DOCTYPE html><html><head><meta charset="utf-8"><style>${tokensCss}\n${cssIndex}</style></head>
<body><button id="b1" class="btn btn-primary btn-md">保存</button><button id="b2" class="btn btn-default btn-md">取消</button></body></html>`);
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 });
}
}
/* 静态校验用:把页面内 <style> 与外链 CSS 全文抓下来,供 Node 侧查是否有该类的规则 */
const cssText = Array.from(document.querySelectorAll('style')).map((s) => s.textContent || '').join('\n');
return { results, cssTextLen: cssText.length, cssText };
},
probeArg: dims,
});
const eff = (effProbe.probe && effProbe.probe.results) || [];
const pageCss = (effProbe.probe && effProbe.probe.cssText) || '';
/* 组件自己的 CSS 文件也纳入检索范围(演示页多为内嵌 <style>,但实现文件是另一处真源) */
let fileCss = '';
try {
const cssPath = join(ROOT, 'frameworks', file.replace(/\.html$/, '.css'));
if (existsSync(cssPath)) fileCss = readFileSync(cssPath, 'utf8');
} catch {
fileCss = '';
}
const allCss = pageCss + '\n' + fileCss;
/* 只有「摘类无变化」且「两处 CSS 都找不到以该类为选择器的规则」才算真失效 */
const ineffective = eff.filter((x) => {
if (x.changed) return false;
const re = new RegExp('\\.' + x.carrier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\w-])');
return !re.test(allCss);
});
const pr = r.probe || {};
const perDim = pr.perDim || [];
const usable = perDim.filter((d) => !d.insufficient);
const bad = usable.filter((d) => d.distinct !== d.total);
if (r.status !== 200) {
record(false, `${slug} 变体视觉可分`, `HTTP ${r.status}`);
} else if (ineffective.length) {
/* 变体类不生效是**硬缺陷**:无论演示页渲染了几个变体,摘掉类样式完全不变就说明它没起作用。 */
record(
false,
`${slug} 变体类生效`,
`${ineffective.length} 个变体类摘除后视觉无任何变化: ` +
ineffective.slice(0, 5).map((x) => `${x.dim}.${x.val}(${x.carrier})`).join(', ')
);
} else if (!usable.length) {
const decl = perDim.map((d) => `${d.dim}:${d.total}/${d.declared}`).join(', ');
console.log(` SKIP ${slug} 变体视觉可分 — 无维度渲染出 ≥2 个变体值(N/A;${decl});已核实 ${eff.filter((x) => x.changed).length} 个变体类均生效`);
} else {
record(
bad.length === 0,
`${slug} 变体视觉可分`,
bad.length
? bad.map((d) => `维度 ${d.dim}: ${d.total - d.distinct} 个值与其它值视觉相同 → ${d.collisions.slice(0, 4).join(', ')}`).join(' ; ')
: usable.map((d) => `${d.dim} ${d.distinct}/${d.total}`).join(', ')
);
}
}
}
/* ---------- group: interaction ----------
* 「能不能正常用」——用真实用户动作去点、去输入、去按 Tab,而不是查 DOM 里有没有那个元素。
*
* 判据(避免把"静态组件"误判成缺陷):
* - 页面上找不到任何可交互元素 → 记 N/A(进度里显示 skip),不算失败。
* 本仓库 PC 侧本就有 50 条 N/A(静态组件无交互面),这条与其口径一致。
* - 有可交互元素时:逐个点击,**控制台不得新增 error/pageerror**;
* 全部点完后 DOM 签名(类名+属性+子节点数)必须至少变化一次 ——
* 一次都不变说明"点了没反应",组件在实机上不可用。
* - 文本框:填入值后 value 必须真的被设置(受控组件写不进去即失败)。
* - 键盘:Tab 至少能移动到 2 个不同元素(焦点可达性)。
*/
async function groupInteraction(browser) {
const platform = arg('--platform', 'pc');
const dataFile = platform === 'mobile' ? ['site', 'm', 'data.mobile.json'] : ['site', 'data.json'];
const dir = platform === 'mobile' ? 'frameworks-mobile' : 'frameworks';
const data = JSON.parse(readFileSync(join(ROOT, ...dataFile), 'utf8'));
const slugs = data.components.map((c) => c.slug);
const htmlList = (await import('node:fs')).readdirSync(join(ROOT, dir)).filter((f) => f.endsWith('.html'));
const bySlug = new Map(htmlList.map((f) => [f.replace(/\.html$/, '').toLowerCase(), f]));
const [si, sn] = SHARD.split('/').map(Number);
const mine = slugs.filter((_, i) => i % sn === si);
console.log(`[interaction:${platform}] 分片 ${SHARD} · 本片 ${mine.length}/${slugs.length}`);
let na = 0;
for (const slug of mine) {
const file = bySlug.get(slug) || htmlList.find((f) => f.toLowerCase().replace(/[^a-z0-9]/g, '') === slug.replace(/[^a-z0-9]/g, ''));
if (!file) { record(false, `${slug} 交互`, '无演示页'); continue; }
const p = await browser.newPage({
viewport: platform === 'mobile' ? { width: 390, height: 844 } : { width: 1200, height: 800 },
hasTouch: platform === 'mobile',
});
/* 在页面脚本执行前埋点,统计"真的注册了事件处理"的次数。
为什么需要它:演示页常含**装饰性** <button>(没有任何 handler,只做视觉展示)——
实测 EmptyPro / ResultVariants / ExceptionVariants / Icon / Pagination 的按钮
CDP 实测监听器数为 0,点它们本来就不该有任何反应。
若不区分,"点了没反应"会把"没有交互面"误解成"组件点不动"(曾误报 8 条)。
计数来源:addEventListener + 内联 on* 属性 + 页面上 <script> 里的 on*= 赋值。 */
await p.addInitScript(() => {
window.__koleRegistered = 0;
const orig = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (...args) {
window.__koleRegistered++;
return orig.apply(this, args);
};
});
const errors = [];
p.on('console', (m) => { if (m.type() === 'error') errors.push(m.text().slice(0, 180)); });
p.on('pageerror', (e) => errors.push('pageerror: ' + String(e.message).slice(0, 180)));
p.on('dialog', (d) => d.dismiss().catch(() => {}));
let navigated = false;
p.on('framenavigated', (f) => { if (f === p.mainFrame()) navigated = true; });
let status = 0;
try {
const resp = await p.goto(`${BASE}/${dir}/${file}`, { waitUntil: 'load', timeout: 20000 });
status = resp ? resp.status() : 0;
await p.waitForTimeout(600);
} catch (e) {
errors.push('goto: ' + String(e.message).slice(0, 140));
}
if (status !== 200) {
record(false, `${slug} 交互`, `HTTP ${status}`);
await p.close();
continue;
}
/* 可交互元素:真实用户能点/能敲的东西 */
const targets = await p.evaluate(() => {
const sel = 'button, input:not([type=hidden]), select, textarea, a[href], [role="button"], [tabindex]:not([tabindex="-1"])';
const els = Array.from(document.querySelectorAll(sel)).filter((el) => {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return r.width > 0 && r.height > 0 && cs.display !== 'none' && cs.visibility !== 'hidden' && !el.disabled;
});
/* 打标记,供后续按序号点击(避免 stale handle)。
用 data-* 序号而不是 className 做焦点去重键 —— 同构元素(14 个 .kole-menu-item)
按 className 去重会被折叠成 1 个,误报"焦点只到 1 个元素"(实测踩到 sidemenu/topmenu/rate)。 */
els.forEach((el, i) => el.setAttribute('data-kole-probe', String(i)));
/* DOM 签名:**必须覆盖属性级与 IDL 级状态**。
早期版本只记 className + children.length,导致 checked/value/hidden/style/aria-expanded
这些变化全部看不见 —— 实测 50 条误报(dropdown 的 hidden 移除、radio 的 checked 迁移、
tooltip 的 hidden 翻转、inputnumber 的 value 变化…)。判据修正理由与反例见文件头。 */
const sig = (root) => {
const parts = [];
root.querySelectorAll('*').forEach((el) => {
const cls = (el.className && el.className.baseVal !== undefined ? el.className.baseVal : el.className) || '';
const attrs = [];
for (const a of el.attributes || []) {
if (a.name === 'data-kole-probe') continue; // 探针自己的标记不算变化
attrs.push(a.name + '=' + String(a.value).slice(0, 40));
}
attrs.sort();
/* IDL 属性:attribute 不变但 IDL 变的场景(radio.checked、input.value)必须能看见 */
const idl = [
el.checked === undefined ? '' : 'c' + el.checked,
el.value === undefined ? '' : 'v' + String(el.value).slice(0, 40),
el.hidden === undefined ? '' : 'h' + el.hidden,
el.disabled === undefined ? '' : 'd' + el.disabled,
el.open === undefined ? '' : 'o' + el.open,
].join(',');
parts.push(cls + '[' + attrs.join(';') + ']' + idl + '#' + el.children.length + 'T' + (el.children.length===0 ? (el.textContent||'').trim().slice(0,20) : ''));
});
return parts.join('|');
};
const hash = (s) => {
/* 简单 32 位散列:整页签名可能很长,直接比字符串更省内存也够灵敏 */
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return s.length + ':' + h;
};
return {
count: els.length,
kinds: els.map((e) => e.tagName.toLowerCase() + (e.type ? ':' + e.type : '')).slice(0, 40),
before: hash(sig(document.body)),
textInputs: Array.from(document.querySelectorAll('input[type=text], input:not([type]), textarea')).length,
};
});
if (targets.count === 0) {
na++;
console.log(` SKIP ${slug} 交互 — 无可交互元素(静态组件)`);
await p.close();
continue;
}
/* 逐个点击(最多 8 个,避免坏元素拖慢整轮) */
let clicked = 0;
const clickFails = [];
for (let i = 0; i < Math.min(targets.count, 8); i++) {
const urlBefore = p.url();
try {
await p.click(`[data-kole-probe="${i}"]`, { timeout: 1500, noWaitAfter: true });
clicked++;
await p.waitForTimeout(90);
} catch (e) {
clickFails.push(`${i}:${String(e.message).slice(0, 40)}`);
}
/* 链接/提交导致跳转就退回来,别把交互测试变成导航测试 */
if (p.url() !== urlBefore) {
await p.goBack({ timeout: 5000 }).catch(() => {});
await p.waitForTimeout(200);
}
}
/* 文本框:真敲字,断言 value 被写入。
注意用的测试串必须是**各类型输入框都合法**的形态:早期用中文 'kole测试',
而 BankCardInput / PlateInput / PhoneInput / CodeInput / IDCardInput 都会按自己的规则
净化输入(数字框剔除中文)→ value 被正确地清空,却被记成"写入失败"(实测 5 条误报)。
改用数字串 '1234' 后这些组件的净化逻辑不会把它丢光。 */
let inputProbe = { checked: 0, accepted: 0 };
const inputCount = await p.evaluate(() => document.querySelectorAll('input[type=text], input:not([type]), textarea').length);
if (inputCount > 0) {
inputProbe = await p.evaluate(() => {
const ins = Array.from(document.querySelectorAll('input[type=text], input:not([type]), textarea'))
.filter((el) => !el.disabled && !el.readOnly && el.getBoundingClientRect().height > 0);
let accepted = 0;
ins.slice(0, 5).forEach((el) => {
el.focus();
el.value = '1234';
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
/* 只要组件保留了这个值的一部分就算"写入被接受":
格式化类组件(卡号插空格、车牌补位)会改写 value,那是它的正常职责 */
if (/[1-4]/.test(String(el.value))) accepted++;
});
return { checked: Math.min(ins.length, 5), accepted };
});
}
/* 键盘:Tab 是否真的能移动焦点。
去重键用探针序号(唯一),而不是 tag+className —— 同构元素会被错误折叠。 */
const seen = new Set();
for (let i = 0; i < 6; i++) {
await p.keyboard.press('Tab').catch(() => {});
const cur = await p.evaluate(() => {
const a = document.activeElement;
if (!a || a === document.body) return null;
/* 用元素身份:优先探针序号,其次 id/name,最后 tag+文本摘要 */
return a.getAttribute('data-kole-probe') || a.id || a.tagName + '#' + (a.textContent || '').trim().slice(0, 12);
});
if (cur) seen.add(cur);
}
/* 原生表单控件:**点击不会改变 DOM** 是正确行为(值要靠键盘/选择器改)。
故对 input[type=number|time|date|color|range] 等,用 fill() 真写值并断言 IDL value 变化,
而不是期待点击改变 DOM —— 否则 TimePicker/NumberRangeInput/DatePicker 会被误判
"点了没反应"(实测踩到)。 */
const nativeProbe = await p.evaluate(() => {
const els = Array.from(document.querySelectorAll('input[type=number], input[type=time], input[type=date], input[type=color], input[type=range], input[type=datetime-local]'))
.filter((el) => !el.disabled && el.getBoundingClientRect().height > 0);
els.forEach((el, i) => el.setAttribute('data-kole-native', String(i)));
return { count: els.length, before: els.map((el) => String(el.value)).join('|') };
});
if (nativeProbe.count > 0) {
for (let i = 0; i < Math.min(nativeProbe.count, 4); i++) {
const sel = `[data-kole-native="${i}"]`;
const type = await p.getAttribute(sel, 'type').catch(() => null);
const sample =
type === 'time' ? '10:30' : type === 'date' ? '2020-01-01' : type === 'datetime-local' ? '2020-01-01T10:30'
: type === 'color' ? '#123456' : '42';
await p.fill(sel, sample).catch(() => {});
}
}
const nativeAfter = await p.evaluate(() => {
const els = Array.from(document.querySelectorAll('[data-kole-native]'));
return { after: els.map((el) => String(el.value)).join('|') };
});
const nativeChanged = nativeProbe.count > 0 && nativeAfter.after !== nativeProbe.before;
const probed = await p.evaluate(() => {
const parts = [];
document.body.querySelectorAll('*').forEach((el) => {
const cls = (el.className && el.className.baseVal !== undefined ? el.className.baseVal : el.className) || '';
const attrs = [];
for (const a of el.attributes || []) {
if (a.name === 'data-kole-probe') continue;
attrs.push(a.name + '=' + String(a.value).slice(0, 40));
}
attrs.sort();
const idl = [
el.checked === undefined ? '' : 'c' + el.checked,
el.value === undefined ? '' : 'v' + String(el.value).slice(0, 40),
el.hidden === undefined ? '' : 'h' + el.hidden,
el.disabled === undefined ? '' : 'd' + el.disabled,
el.open === undefined ? '' : 'o' + el.open,
].join(',');
parts.push(cls + '[' + attrs.join(';') + ']' + idl + '#' + el.children.length + 'T' + (el.children.length===0 ? (el.textContent||'').trim().slice(0,20) : ''));
});
const s = parts.join('|');
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
/* 顺手统计该页是否真的注册过事件处理(含内联 on* 与脚本里的 on*= 赋值)。
放在关闭页面**之前**取,否则 evaluate 会因 Target closed 抛错(实测踩到)。 */
const reg = window.__koleRegistered || 0;
const inlineOn = Array.from(document.querySelectorAll('*')).filter((el) =>
Array.from(el.attributes || []).some((a) => /^on[a-z]+$/.test(a.name))
).length;
const scripts = Array.from(document.querySelectorAll('script')).map((sc) => sc.textContent || '').join('');
const scriptOn = (scripts.match(/on(click|input|change|pointer\w+|touch\w+|mousedown|mouseup|keydown|keyup)\s*=/g) || []).length;
return { sig: s.length + ':' + h, wiring: { reg, inlineOn, scriptOn, total: reg + inlineOn + scriptOn }, radios: document.querySelectorAll('input[type=radio]').length };
});
await p.close();
const after = probed.sig;
const wiring = probed.wiring;
const focusStat = probed.radios || 0;
const changed = after !== targets.before || nativeChanged;
const inputOk = inputProbe.checked === 0 || inputProbe.accepted > 0;
/* 有交互元素、但整页**零事件注册** → 这些"按钮"是装饰性的,没有交互面可测 → N/A。
这与本仓库既有的 50 条 N/A(静态组件无交互面)是同一口径。
注意:nativeChanged 为真时说明原生控件确实响应了,不算"无交互面"。 */
if (wiring.total === 0 && !changed) {
na++;
console.log(` SKIP ${slug} 交互 — 有 ${targets.count} 个可交互元素但整页零事件注册(装饰性,N/A)`);
await p.close();
continue;
}
/* 焦点判据:
- 页面只有 1 个可聚焦元素时 Tab 只能到 1 个 —— 那是页面结构,不是缺陷。
- **原生 radio 组是单一 Tab 停靠点**(组内用方向键切换),这是 HTML 规定行为,
不是"焦点移动失败"(实测 radio 被误报)。故把 radio 全并成 1 个有效停靠点再计数。 */
const focusOk = targets.count <= 1 || seen.size >= 2;
record(
errors.length === 0 && changed && inputOk && (focusOk || (focusStat >= 2 && seen.size >= 1)),
`${slug} 交互可用`,
[
errors.length ? `${errors.length} 控制台错误: ${errors.slice(0, 2).join(' || ')}` : null,
!changed ? `点击 ${clicked} 个元素后 DOM 无任何变化(已注册 ${wiring.reg} 个监听器 + ${wiring.inlineOn} 个内联 on*,点了没反应)` : null,
!inputOk ? `文本框写入失败 ${inputProbe.accepted}/${inputProbe.checked}` : null,
!focusOk && !(focusStat >= 2 && seen.size >= 1)
? `Tab 后焦点只到 ${seen.size} 个元素(页面 ${targets.count} 个可交互,其中原生 radio ${focusStat} 个;radio 组按 HTML 语义只占 1 个停靠点)`
: null,
clickFails.length ? `点击未能执行 ${clickFails.length} 个: ${clickFails.slice(0, 2).join(', ')}` : null,
].filter(Boolean).join(' ; ') ||
`可交互 ${targets.count} 个 / 点击 ${clicked} / 文本框 ${inputProbe.accepted}/${inputProbe.checked} / 焦点 ${seen.size} 处 / 注册 ${wiring.total}` +
(nativeProbe.count ? ` / 原生控件 ${nativeProbe.count} 个${nativeChanged ? '(值已变更)' : ''}` : '')
);
}
if (na) console.log(`[interaction:${platform}] ${na} 个页面无交互面(N/A,不计失败)`);
}
/* ---------- group: compile-ends ----------
* 每个 JS 端(React JSX / Vue3 SFC / Vue2 SFC)的**全部**实现文件逐个真编译。
* 一个编译不过的组件,用户 import 时直接报错 —— 这是"能不能用"的硬底线。
* 覆盖 dist/ 与 frameworks/ 两侧(dist 是发布形态,frameworks 是源码形态)。
*/
async function groupCompileEnds(browser) {
const { createRequire } = await import('node:module');
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'));
let esbuild, sfc3, tc2;
try {
esbuild = req('esbuild');
sfc3 = req('@vue/compiler-sfc');
tc2 = req2('vue-template-compiler');
} catch (e) {
console.error('[FATAL] 缺少编译依赖:' + e.message);
process.exit(2);
}
const NODE_PATHS = [join(DEPS, 'node_modules'), join(DEPS2, 'node_modules')];
const fs = await import('node:fs');
/* Vue3 的 .vue 需要走 esbuild + SFC 插件才叫"真编译"(含 template 编译) */
const vue3Plugin = {
name: 'sfc3',
setup(build) {
build.onLoad({ filter: /\.vue$/ }, (args) => {
const source = fs.readFileSync(args.path, 'utf8');
const { descriptor, errors } = sfc3.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 = sfc3.compileScript(descriptor, { id });
const tpl = descriptor.template
? sfc3.compileTemplate({
source: descriptor.template.content,
filename: args.path,
id,
compilerOptions: { bindingMetadata: script.bindings },
})
: { code: 'export function render(){return null}' };
if (tpl.errors && tpl.errors.length) throw new Error('template: ' + tpl.errors[0].message || tpl.errors[0]);
return {
contents: `${script.content.replace(/export\s+default/, 'const __m =')}\n${tpl.code.replace(/export\s+function\s+render/, 'function __r')}\n__m.render = __r;\nexport default __m;`,
loader: 'js',
resolveDir: dirname(args.path),
};
});
},
};
const [si, sn] = SHARD.split('/').map(Number);
/* --- React(dist/react/*.jsx,source=verif) --- */
const reactFiles = fs.readdirSync(join(ROOT, 'dist', 'react')).filter((f) => f.endsWith('.jsx'));
{
const mine = reactFiles.filter((_, i) => i % sn === si);
let bad = [];
for (const f of mine) {
try {
await esbuild.build({
entryPoints: [join(ROOT, 'dist', 'react', f)],
bundle: true, write: false, format: 'esm',
external: ['react', 'react-dom'], nodePaths: NODE_PATHS,
loader: { '.css': 'empty' }, logLevel: 'silent',
});
} catch (e) {
bad.push(f + ' :: ' + String(e.errors?.[0]?.text || e.message).slice(0, 120));
}
}
record(bad.length === 0, `React 全量真编译(本片 ${mine.length} 个)`, bad.slice(0, 6).join(' ; ') || null);
bad.forEach((b) => console.log(' ✗ ' + b));
}
/* --- Vue 3 --- */
const v3 = fs.readdirSync(join(ROOT, 'dist', 'vue3')).filter((f) => f.endsWith('.vue'));
{
const mine = v3.filter((_, i) => i % sn === si);
let bad = [];
for (const f of mine) {
try {
await esbuild.build({
entryPoints: [join(ROOT, 'dist', 'vue3', f)],
bundle: true, write: false, format: 'esm',
external: ['vue'], plugins: [vue3Plugin], nodePaths: NODE_PATHS,
loader: { '.css': 'empty' }, logLevel: 'silent',
});
} catch (e) {
bad.push(f + ' :: ' + String(e.errors?.[0]?.text || e.message).slice(0, 120));
}
}
record(bad.length === 0, `Vue3 全量真编译(本片 ${mine.length} 个)`, bad.slice(0, 6).join(' ; ') || null);
bad.forEach((b) => console.log(' ✗ ' + b));
}
/* --- Vue 2(含源码侧 frameworks/*.vue2.vue 对照) --- */
for (const [label, dir, suffix] of [['Vue2(dist)', join(ROOT, 'dist', 'vue2'), '.vue'], ['Vue2(源码)', join(ROOT, 'frameworks'), '.vue2.vue']]) {
const all = fs.readdirSync(dir).filter((f) => f.endsWith(suffix));
const mine = all.filter((_, i) => i % sn === si);
let bad = [];
for (const f of mine) {
const src = fs.readFileSync(join(dir, f), 'utf8');
const parsed = tc2.parseComponent(src);
if (parsed.template) {
const c = tc2.compile(parsed.template.content);
if (c.errors && c.errors.length) bad.push(f + ' :: ' + c.errors.join(' | ').slice(0, 140));
}
/* script 侧也过一遍 esbuild(抓语法/重复声明这类只在编译期暴露的问题) */
if (parsed.script) {
try {
esbuild.transformSync(parsed.script.content.replace(/export\s+default/, 'const __x ='), { loader: 'js' });
} catch (e) {
bad.push(f + ' :: script: ' + String(e.errors?.[0]?.text || e.message).slice(0, 120));
}
}
}
record(bad.length === 0, `${label} 全量真编译(本片 ${mine.length} 个)`, bad.slice(0, 6).join(' ; ') || null);
bad.forEach((b) => console.log(' ✗ ' + b));
}
}
/* ---------- group: mount-all ----------
* 把**全部**组件在真实浏览器里逐个挂载(每个端一个页面,一次挂载所有组件)。
* 断言:每个组件对应的容器都有 DOM 产出、且全程 0 控制台错误 / 0 未捕获异常。
* 这是"实机环境正常使用"的最强形态 —— 编译过 ≠ 能实例化,实例化可能抛错。
*/
async function groupMountAll(browser) {
const { createRequire } = await import('node:module');
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'));
const esbuild = req('esbuild');
const sfc3 = req('@vue/compiler-sfc');
const tc2 = req2('vue-template-compiler');
const NODE_PATHS = [join(DEPS, 'node_modules'), join(DEPS2, 'node_modules')];
const fs = await import('node:fs');
const OUT = join(OUT_DIR, 'mount-all');
fs.mkdirSync(OUT, { recursive: true });
const tokens = fs.readFileSync(join(ROOT, 'dist', 'tokens', 'tokens.css'), 'utf8');
const compCss = fs.readFileSync(join(ROOT, 'dist', 'components', 'index.css'), 'utf8');
const vue3Plugin = {
name: 'sfc3',
setup(build) {
build.onLoad({ filter: /\.vue$/ }, (args) => {
const source = fs.readFileSync(args.path, 'utf8');
const { descriptor, errors } = sfc3.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 = sfc3.compileScript(descriptor, { id });
const tpl = descriptor.template
? sfc3.compileTemplate({ source: descriptor.template.content, filename: args.path, id, compilerOptions: { bindingMetadata: script.bindings } })
: { code: 'export function render(){return null}' };
if (tpl.errors && tpl.errors.length) throw new Error('template: ' + (tpl.errors[0].message || tpl.errors[0]));
return {
contents: `${script.content.replace(/export\s+default/, 'const __m =')}\n${tpl.code.replace(/export\s+function\s+render/, 'function __r')}\n__m.render = __r;\nexport default __m;`,
loader: 'js', resolveDir: dirname(args.path),
};
});
},
};
/* Vue 2 的 render 是 `with(this){...}`。esbuild 只要把输入当 ESM 解析就会判 strict 并报
"With statements cannot be used in an ECMAScript module" —— 这是**解析模式**问题,与组件无关。
Vue 2 的真实消费形态本就是 CJS/非模块,故这里的插件输出 CJS(module.exports),
让 esbuild 按 CommonJS 解析(非严格),从而能真实编译并挂载。 */
const vue2Plugin = {
name: 'sfc2',
setup(build) {
build.onLoad({ filter: /\.vue$/ }, (args) => {
const src = fs.readFileSync(args.path, 'utf8');
const parsed = tc2.parseComponent(src);
let scriptContent = parsed.script ? parsed.script.content : 'export default {}';
/* 本库的 vue2 实现是纯 <script>(无 import),只需把唯一的一次 export default 换成 CJS 赋值 */
scriptContent = scriptContent.replace(/export\s+default\s+/, 'module.exports = ');
if (parsed.template) {
const c = tc2.compile(parsed.template.content);
if (c.errors && c.errors.length) throw new Error('vue2 template: ' + c.errors[0]);
/* vue-template-compiler 的 compile().render 返回的是**函数体字符串**(`with(this){…}`),
不是可直接调用的函数 —— 直接当代码插入会让 _c / _v 在模块作用域求值而报
"_c is not defined"(实测踩到,表现为 103 个组件全部挂载失败)。
正解与 vue-loader 一致:包成 real function 再挂到选项对象上。 */
return {
contents: `${scriptContent}\nmodule.exports.render = function () { ${c.render} };\nmodule.exports.staticRenderFns = [${(c.staticRenderFns || []).map((fn) => `function () { ${fn} }`).join(',')}];`,
loader: 'js', resolveDir: dirname(args.path),
};
}
return { contents: scriptContent, loader: 'js', resolveDir: dirname(args.path) };
});
},
};
const ends = [
{
key: 'react', label: 'React', ext: '.jsx', plugin: [],
host: `
import React from 'react';
import { createRoot } from 'react-dom/client';
import * as Kole from './entry.mjs';
const PROPS = __PROPS__;
const names = Object.keys(Kole).filter((k) => /^Kole/.test(k) && (typeof Kole[k] === 'function' || (Kole[k] && typeof Kole[k] === 'object')));
const results = [];
for (const n of names) {
const host = document.createElement('div');
host.className = 'kole-mount-cell'; host.id = 'cell-' + n;
document.getElementById('grid').appendChild(host);
const rec = { name: n, ok: false, err: null, domLen: 0, empty: false };
try {
createRoot(host).render(React.createElement(Kole[n], PROPS));
} catch (e) { rec.err = String((e && e.message) || e).slice(0, 160); }
results.push(rec);
}
window.__koleNames = names;
window.__koleSync = () => {
for (const rec of results) {
const el = document.getElementById('cell-' + rec.name);
rec.domLen = el ? el.innerHTML.length : 0;
rec.empty = rec.domLen === 0;
rec.ok = rec.domLen > 0 && !rec.err;
}
window.__koleResults = results;
window.__koleCount = results.length;
document.title = 'DONE:' + results.filter((r) => r.ok).length + '/' + results.length;
};
`,
},
{
key: 'vue3', label: 'Vue 3', ext: '.vue', plugin: [vue3Plugin],
host: `
import { createApp, h } from 'vue';
import * as Kole from './entry.mjs';
const PROPS = __PROPS__;
const names = Object.keys(Kole).filter((k) => /^Kole/.test(k) && Kole[k] && typeof Kole[k] === 'object');
const results = [];
for (const n of names) {
const host = document.createElement('div');
host.className = 'kole-mount-cell'; host.id = 'cell-' + n;
document.getElementById('grid').appendChild(host);
const rec = { name: n, ok: false, err: null, domLen: 0, empty: false };
try {
createApp({ render: () => h(Kole[n], PROPS) }).mount(host);
} catch (e) { rec.err = String((e && e.message) || e).slice(0, 160); }
results.push(rec);
}
window.__koleNames = names;
window.__koleSync = () => {
for (const rec of results) {
const el = document.getElementById('cell-' + rec.name);
rec.domLen = el ? el.innerHTML.length : 0;
rec.empty = rec.domLen === 0;
rec.ok = rec.domLen > 0 && !rec.err;
}
window.__koleResults = results;
window.__koleCount = results.length;
document.title = 'DONE:' + results.filter((r) => r.ok).length + '/' + results.length;
};
`,
asyncReady: true,
},
{
key: 'vue2', label: 'Vue 2', ext: '.vue', plugin: [vue2Plugin],
/* 入口是 IIFE,导出挂在 globalThis.__Kole2 上(见下方 isVue2 说明) */
host: `
import Vue from 'vue';
const PROPS = __PROPS__;
const Kole = globalThis.__Kole2 || {};
const names = Object.keys(Kole).filter((k) => /^Kole/.test(k) && Kole[k]);
const results = [];
for (const n of names) {
/* 外层 wrap 用于**测量**:Vue2 的 $mount(el) 会替换挂载点本身,
直接读挂载点会在挂载后被判成"空"(实测踩到:20 个组件被误报空渲染)。 */
const wrap = document.createElement('div'); wrap.className = 'kole-mount-wrap'; wrap.id = 'cell-' + n;
const host = document.createElement('div');
wrap.appendChild(host);
document.getElementById('grid').appendChild(wrap);
const rec = { name: n, ok: false, err: null, domLen: 0, empty: false };
try {
const comp = Kole[n];
new Vue({
render: (h) => h(comp.default || comp, { props: PROPS }),
}).$mount(host);
} catch (e) { rec.err = String((e && e.message) || e).slice(0, 160); }
results.push(rec);
}
window.__koleNames = names;
window.__koleSync = () => {
for (const rec of results) {
const el = document.getElementById('cell-' + rec.name);
rec.domLen = el ? el.innerHTML.length : 0;
rec.empty = rec.domLen === 0;
rec.ok = rec.domLen > 0 && !rec.err;
}
window.__koleResults = results;
window.__koleCount = results.length;
document.title = 'DONE:' + results.filter((r) => r.ok).length + '/' + results.length;
};
`,
asyncReady: true,
},
];
/* 通用 prop 袋:**按组件自身声明的契约**填必填项与数据类 prop。
* 不填这些,Table/Tree 会**正确地**渲染成空、EmployeeCard/Transfer 会因 required 缺失而抛错 ——
* 那是"调用方违约",不是组件缺陷。判据不含糊:给足契约要求的 prop 后仍崩溃/仍空白,才算缺陷。
* 不填 visible/open/show:modal / drawer / popup 关闭时不渲染内容是正确行为。 */
const DATA_PROPS = {
text: '测试', title: '标题', label: '标签', name: '名称', content: '内容', placeholder: '请输入', value: '',
/* 数组项同时带 key/label/id/name —— 满足 Transfer(key,label) / Table(id,name,amount) / Tree 等多种契约 */
items: [{ key: 'k1', label: '选项一', id: 1, name: '选项一' }, { key: 'k2', label: '选项二', id: 2, name: '选项二' }],
options: [{ value: 'a', label: '选项一' }, { value: 'b', label: '选项二' }],
data: [{ key: 'r1', label: '示例一', id: 1, name: '示例一', status: '进行中', amount: 1200 }, { key: 'r2', label: '示例二', id: 2, name: '示例二', status: '已完成', amount: 3400 }],
columns: [{ key: 'name', title: '名称' }, { key: 'amount', title: '金额', align: 'right' }],
rows: [{ id: 1, name: '示例一' }],
tabs: [{ key: 't1', label: '标签一' }, { key: 't2', label: '标签二' }],
steps: [{ title: '第一步' }, { title: '第二步' }],
nodes: [{ key: 'n1', label: '节点一', children: [{ key: 'n1-1', label: '子节点' }] }],
list: [{ key: 'l1', label: '列表项一' }],
panels: [{ key: 'p1', title: '面板一', content: '内容一' }],
/* EmployeeCard 的 required prop(源码声明 employee: { type: Object, required: true }) */
employee: { name: '张三', empId: 'E001', dept: '技术部', position: '工程师', status: 'active' },
/* 注意:**不**放 modelValue —— 它在本库里类型不统一(BankCardInput/CodeInput/IDCardInput 是
String,Transfer 是 Array)。硬塞一个类型,会让"契约要求 String 却收到 Array"的调用方违约
被误记成组件缺陷(实测踩过:三个组件报 replace/split/toUpperCase is not a function)。
不放 → 各组件用自己声明的默认值,正好覆盖"仅用默认值能否实例化"这一最基本场景。 */
active: 'k1',
};
/* 逐端:优先生成「逐文件导出」入口(避开 index.js 里的坏文件连坐,让好文件照样被验证),
并记录哪些文件因编译失败被排除 —— 排除项本身就是缺陷,单独如实报告。 */
for (const end of ends) {
const outDir = join(OUT, end.key);
fs.mkdirSync(outDir, { recursive: true });
const srcDir = join(ROOT, 'dist', end.key);
const files = fs.readdirSync(srcDir).filter((f) => f.endsWith(end.ext)).sort();
const exported = [];
const broken = [];
for (const f of files) {
const n = 'Kole' + f.replace(new RegExp(end.ext.replace('.', '\\.') + '$'), '');
if (end.key === 'vue2') {
/* Vue2:先单独判这份 SFC 能否编译;不能编译的**排除出挂载**并计入失败 */
const src = fs.readFileSync(join(srcDir, f), 'utf8');
const parsed = tc2.parseComponent(src);
const c = parsed.template ? tc2.compile(parsed.template.content) : { errors: [] };
if (c.errors && c.errors.length) { broken.push(f + ' :: ' + c.errors[0]); continue; }
}
exported.push(`export { default as ${n} } from ${JSON.stringify(join(srcDir, f).replace(/\\/g, '/'))};`);
}
/* Vue2 的 render 是 `with(this){...}`:esbuild 只要把入口当 ESM 解析就会报
"With statements cannot be used in an ECMAScript module"(输出格式改成 iife 也没用 ——
严格与否取决于**输入**如何被解析)。故 Vue2 入口写成 CJS 形态的 require(),
让 esbuild 按 CommonJS 解析,再打成 IIFE 挂到 __Kole2。 */
if (end.key === 'vue2') {
/* 插件已把 SFC 输出为 CJS(module.exports = 组件选项),故这里直接取 require() 的结果,
不能再写 `.default ||` —— 那样取到的是选项对象上的 default 属性(undefined),
会被 `||` 兜回 require 结果看似正确,但若组件同时导出 default 就会串味。
实测踩到:写成 `.default || require()` 时入口里 `module.exports` 被 esbuild 包成
`{ default: opts }`,宿主读到的是包装对象,new Vue() 直接不产出任何 DOM。 */
const cjs = exported
.map((line) => {
const m = line.match(/export \{ default as (\w+) \} from "([^"]+)";/);
return `exports.${m[1]} = require(${JSON.stringify(m[2])});`;
})
.join('\n');
fs.writeFileSync(join(outDir, 'entry.cjs'), cjs);
} else {
fs.writeFileSync(join(outDir, 'entry.js'), exported.join('\n'));
}
if (broken.length) {
record(false, `${end.label} 可编译(${files.length - broken.length}/${files.length})`, broken.slice(0, 4).join(' ; '));
} else {
record(true, `${end.label} 全部 ${files.length} 个文件可编译`, null);
}
if (!exported.length) continue;
/* Vue 2 的 render 代码是 `with(this){...}` —— 在 ESM(严格模式)里非法,故 Vue2 走 IIFE 并挂到
globalThis.__Kole2;React/Vue3 仍走 ESM。这不是放宽判据:Vue2 官方编译器输出的就是这段代码,
用严格模式打包会得到"With statements cannot be used in an ECMAScript module"这种**工具误用**,
与组件本身能否工作无关。 */
/* Vue2 端把 'vue' 别名到 Vue2 那棵树 —— 否则 esbuild 会解析到 verify-import 里的 Vue 3,
报 "No matching export ... for import default",那是**依赖串台**而非组件问题。
React / Vue3 端不加别名:各自的 nodePaths 已能正确解析(加别名反而把 react-dom 指向 CJS 入口)。 */
const ALIAS = end.key === 'vue2' ? { vue: join(DEPS2, 'node_modules', 'vue', 'dist', 'vue.runtime.esm.js') } : undefined;
const isVue2 = end.key === 'vue2';
const b = await p_esbuildBuild(esbuild, {
entryPoints: [join(outDir, isVue2 ? 'entry.cjs' : 'entry.js')],
bundle: true,
format: isVue2 ? 'iife' : 'esm',
globalName: isVue2 ? '__Kole2' : undefined,
outfile: join(outDir, isVue2 ? 'entry.iife.js' : 'entry.mjs'),
plugins: end.plugin, external: ['react', 'react-dom', 'vue'], nodePaths: NODE_PATHS,
loader: { '.css': 'empty' }, logLevel: 'silent',
});
if (!b.ok) {
record(false, `${end.label} 挂载包编译`, b.err);
continue;
}
fs.writeFileSync(join(outDir, 'host.js'), end.host.replace('__PROPS__', JSON.stringify(DATA_PROPS)));
/* 宿主包:把运行时就地内联(iife 里没有 import 语义,把 react/vue 留作 external 会变成
无法解析的裸引用 —— 实测会让"全部组件都不渲染",那是打包配置问题而非组件问题)。 */
const h = await p_esbuildBuild(esbuild, {
entryPoints: [join(outDir, 'host.js')], bundle: true, format: 'iife', outfile: join(outDir, 'host.iife.js'),
plugins: end.plugin, nodePaths: NODE_PATHS, alias: ALIAS,
loader: { '.css': 'empty' }, logLevel: 'silent',
});
if (!h.ok) {
record(false, `${end.label} 宿主包编译`, h.err);
continue;
}
/* Vue2 宿主与入口合并成一个脚本按序注入(入口 IIFE 先定义 __Kole2,宿主再消费);
其余端只需注入宿主包(其依赖已在打包时内联,或走 externals 由页面提供)。 */
const bundleJs = isVue2
? fs.readFileSync(join(outDir, 'entry.iife.js'), 'utf8') + '\n' + fs.readFileSync(join(outDir, 'host.iife.js'), 'utf8')
: fs.readFileSync(join(outDir, 'host.iife.js'), 'utf8');
const p = await browser.newPage({ viewport: { width: 1280, height: 900 } });
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(`<!DOCTYPE html><html><head><meta charset="utf-8"><style>${tokens}\n${compCss}</style></head><body><div id="grid"></div></body></html>`);
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}`);