Regression / regression (push) Canceled after 0s
## 品牌标识(本次会话) 起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」, 那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」, 移动端站已是「K」;移动端文档站则完全没有 favicon)。 - 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边; 描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」 - 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark); 顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28)) - 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg) - 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留) - 移动端 7 个模板补 favicon(此前计数 0) 验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/ theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致; 两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。 ## 并行会话成果(本次一并入库) - 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具 + IconPreview 预览页 + ICON-SPEC.md 冻结规格 - 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁 - PC 组件:103 个大后台组件 / 组件11 批次 - uni-app:PC 端试点 + 移动端端实现 + 真实编译验证 ## 工程 - .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库) - CHANGELOG 补品牌标识条目 - ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
1631 lines
83 KiB
JavaScript
1631 lines
83 KiB
JavaScript
#!/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}`);
|