@qilitt-mickey/vue3-temp-skill 1.2.2 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,530 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * verify-render.mjs —— 渲染取证:契约 → 浏览器 computed style 逐点比对
5
+ *
6
+ * 用法:
7
+ * node verify-render.mjs --project <项目根> --url http://localhost:5502 [--headed]
8
+ *
9
+ * 行为:
10
+ * 1. 读 .design/visual-contract-*.json(期望值唯一来源)+ adapter JSON(innerTargets/stateChannels)
11
+ * 2. Playwright 登录(验证码 canvas 读取 + 滑块 setupState.success 绕过)
12
+ * 3. 导航到目标页,对每个角色的每个挂载点读 getComputedStyle 逐条比对
13
+ * 4. hover/focus 态用 page.hover/focus 触发后再比
14
+ * 5. 输出差异报告(角色/属性/期望/实际/选择器)+ 截图留档
15
+ *
16
+ * 退出码: 0 = 全部命中 / 1 = 有差异
17
+ */
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { createRequire } from "node:module";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const SKILL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
26
+
27
+ /** 解析 playwright:项目 → 全局(nvm/npm -g)逐级尝试 */
28
+ function resolvePlaywright(projectRoot) {
29
+ const candidates = [];
30
+ try { candidates.push(require.resolve("playwright", { paths: [projectRoot] })); } catch {}
31
+ const globalRoots = [
32
+ process.env.APPDATA && path.join(process.env.APPDATA, "npm", "node_modules"),
33
+ path.dirname(process.execPath), // nvm: node.exe 同级 node_modules
34
+ ].filter(Boolean);
35
+ for (const g of globalRoots) {
36
+ try { candidates.push(require.resolve("playwright", { paths: [g] })); } catch {}
37
+ }
38
+ if (!candidates.length) throw new Error("playwright 不可用(项目与全局均未安装)——降级:人工逐点核(见 design-apply 第 7 步)");
39
+ return require(candidates[0]);
40
+ }
41
+
42
+ function parseArgs(argv) {
43
+ const out = { project: process.cwd(), url: "http://localhost:5502", route: "/#/table/base", headed: false, outDir: ".design" };
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ if (a === "--project" || a === "-p") out.project = path.resolve(argv[++i]);
47
+ else if (a === "--url") out.url = argv[++i];
48
+ else if (a === "--route") out.route = argv[++i];
49
+ else if (a === "--headed") out.headed = true;
50
+ else if (a === "--out") out.outDir = argv[++i];
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function expectedClassName(roleName) {
56
+ return "role-" + roleName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/\./g, "-").replace(/_/g, "-").toLowerCase();
57
+ }
58
+
59
+ /** 令牌展开:{color.brand} → 实值(从契约 tokens 查) */
60
+ function makeTokenResolver(contract) {
61
+ const map = new Map();
62
+ for (const [family, names] of Object.entries(contract.tokens || {})) {
63
+ for (const [name, def] of Object.entries(names)) map.set(`${family}.${name}`, def.value);
64
+ }
65
+ return (value) => String(value).replace(/\{([a-zA-Z0-9.]+)\}/g, (m, ref) => map.get(ref) ?? m);
66
+ }
67
+
68
+ /** 颜色归一:#RGB/#RRGGBB/#RRGGBBAA/rgb()/rgba()/transparent → "r,g,b,a"(a 三位小数) */
69
+ function normColor(v) {
70
+ if (v == null) return null;
71
+ v = String(v).trim().toLowerCase();
72
+ if (v === "transparent") return "0,0,0,0.000";
73
+ let m = v.match(/^#([0-9a-f]{3})$/);
74
+ if (m) { const s = m[1]; return normColor(`#${s[0]}${s[0]}${s[1]}${s[1]}${s[2]}${s[2]}`); }
75
+ m = v.match(/^#([0-9a-f]{6})$/);
76
+ if (m) { const n = parseInt(m[1], 16); return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},1.000`; }
77
+ m = v.match(/^#([0-9a-f]{8})$/);
78
+ if (m) { const n = parseInt(m[1].slice(0, 6), 16); const a = parseInt(m[1].slice(6), 16) / 255; return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a.toFixed(3)}`; }
79
+ m = v.match(/^rgba?\(([^)]+)\)$/);
80
+ if (m) {
81
+ const parts = m[1].split(/[,\s/]+/).filter(Boolean).map(Number);
82
+ const [r, g, b, a = 1] = parts;
83
+ if ([r, g, b].some(Number.isNaN)) return null;
84
+ return `${r},${g},${b},${Number(a).toFixed(3)}`;
85
+ }
86
+ return v;
87
+ }
88
+
89
+ /** 值归一:颜色→normColor;px 列表→统一 "Npx" 逗号串;关键字简写→展开;其余小写去空格 */
90
+ function normValue(prop, v) {
91
+ v = String(v).trim();
92
+ if (/color|background|border|shadow|fill|stroke/i.test(prop) && !/width|radius|size|style/i.test(prop)) {
93
+ // 可能含多段(如 border-bottom: 1px solid rgb(...)):逐 token 处理
94
+ return v.replace(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]+\)|\btransparent\b/g, (c) => normColor(c) ?? c).toLowerCase();
95
+ }
96
+ // 关键字简写展开为 4 值(浏览器返回 4 值,期望可能写简写)
97
+ const KEY4 = { center: ["center", "center"], top: ["top", "bottom"], right: ["right", "left"], bottom: ["bottom", "top"], left: ["left", "right"] };
98
+ if (["justify-content", "align-items", "place-content", "place-items"].includes(prop) && KEY4[v]) return KEY4[v].join(" ");
99
+ // px 列表归一:"0 8px" → "0px 8px"
100
+ if (/^-?\d+(?:\.\d+)?(?:px|%)?(?:\s+|-)/.test(v) || /^-?\d+(?:\.\d+)?(?:px|%)$/.test(v)) {
101
+ return v.split(/[\s,]+/).map((t) => (t === "0" ? "0px" : t)).join(" ");
102
+ }
103
+ return v.toLowerCase();
104
+ }
105
+
106
+ /** 把值切成 token:颜色/函数保持整体,其余按空格/逗号切 */
107
+ function tokenize(v) {
108
+ const out = [];
109
+ const re = /rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}\b|[^\s,]+/g;
110
+ let m;
111
+ while ((m = re.exec(String(v)))) out.push(m[0]);
112
+ return out;
113
+ }
114
+
115
+ /** 单 token 比对:颜色归一(alpha 容差 0.01);px 数值容差;其余字符串相等 */
116
+ function tokenMatch(a, b, tol) {
117
+ const na = normColor(a); const nb = normColor(b);
118
+ const isColorA = /^#|rgba?\(|^transparent$/.test(String(a).trim());
119
+ const isColorB = /^#|rgba?\(|^transparent$/.test(String(b).trim());
120
+ if (isColorA || isColorB) {
121
+ if (na && nb) {
122
+ const pa = na.split(",").map(Number); const pb = nb.split(",").map(Number);
123
+ return pa.slice(0, 3).every((x, i) => Math.abs(x - pb[i]) <= 1) && Math.abs(pa[3] - pb[3]) <= 0.011;
124
+ }
125
+ return false;
126
+ }
127
+ const pa = String(a).match(/^(-?[\d.]+)px$/i);
128
+ const pb = String(b).match(/^(-?[\d.]+)px$/i);
129
+ if (pa && pb) return Math.abs(Number(pa[1]) - Number(pb[1])) <= tol;
130
+ if ((a === "0" || /^0(px)?$/.test(a)) && (b === "0" || /^0(px)?$/.test(b))) return true;
131
+ // flex 子项 blockify:inline-flex/inline-block/inline-grid 计算为块级等价形态
132
+ const BLOCKY = { "inline-flex": "flex", "inline-block": "block", "inline-grid": "grid", "inline-table": "table" };
133
+ if (BLOCKY[String(a).toLowerCase()] === String(b).toLowerCase() || String(a).toLowerCase() === BLOCKY[String(b).toLowerCase()]) return true;
134
+ return String(a).toLowerCase() === String(b).toLowerCase();
135
+ }
136
+
137
+ /** 值比对:逐 token,几何容差 ≤2px */
138
+ function valuesMatch(prop, expected, actual) {
139
+ expected = String(expected).trim();
140
+ actual = String(actual).trim();
141
+ // auto ↔ used px/0px(margin auto、height auto 计算后为 used 值)
142
+ if (expected === "auto" && /^(-?[\d.]+px|0px)$/.test(actual)) return true;
143
+ // unitless line-height(契约 "1")↔ 计算 px:在调用处按 fontSize 精算,这里宽松放行
144
+ if (prop === "line-height" && /^[\d.]+$/.test(expected) && /^[\d.]+px$/.test(actual)) return true;
145
+ // border 简写:期望 "none" ↔ 计算值 "0px none <color>"
146
+ if (expected === "none" && /^0px\s+none\b/.test(actual)) return true;
147
+ // grid-template-columns 等计算后解析为 px 轨道:含 repeat( 的期望无法逐值比 → 视为通过(结构由 CSS 源保证)
148
+ if (expected.includes("repeat(") && /^[\d.]+px(\s+[\d.]+px)*$/.test(actual)) return true;
149
+ if (prop === "box-shadow") {
150
+ if (expected === "none" || actual === "none") return expected === actual;
151
+ const ec = normColor(expected.match(/rgba?\([^)]+\)|#[0-9a-f]{3,8}/i)?.[0]);
152
+ const ac = normColor(actual.match(/rgba?\([^)]+\)|#[0-9a-f]{3,8}/i)?.[0]);
153
+ return ec === ac;
154
+ }
155
+ // 简写展开:期望 "0 8px" 实际 "0px 8px 0px 8px" → 4 值补全
156
+ let et = tokenize(expected);
157
+ let at = tokenize(actual);
158
+ if (et.length === 2 && at.length === 4) et = [et[0], et[1], et[0], et[1]];
159
+ if (et.length === 3 && at.length === 4) et = [et[0], et[1], et[2], et[1]];
160
+ if (et.length === 1 && at.length === 4) et = [et[0], et[0], et[0], et[0]];
161
+ if (et.length !== at.length) return false;
162
+ const tol = /width|height|padding|margin|gap|radius|size|inset|top|right|bottom|left|offset/i.test(prop) ? 2 : 0;
163
+ return et.every((t, i) => tokenMatch(t, at[i], tol));
164
+ }
165
+
166
+ const GEOMETRY_PROPS = new Set(["height", "width", "min-width", "max-width", "padding", "padding-top", "padding-right", "padding-bottom", "padding-left", "margin", "gap", "border-radius", "font-size", "line-height"]);
167
+
168
+ async function main() {
169
+ const args = parseArgs(process.argv.slice(2));
170
+ const designDir = path.join(args.project, ".design");
171
+ const contractFile = fs.readdirSync(designDir).find((n) => /^visual-contract.*\.json$/.test(n));
172
+ const contract = JSON.parse(fs.readFileSync(path.join(designDir, contractFile), "utf-8"));
173
+ const adapter = JSON.parse(fs.readFileSync(path.join(SKILL_DIR, "adapters", "vue-element-plus.json"), "utf-8"));
174
+ const resolveToken = makeTokenResolver(contract);
175
+
176
+ const { chromium } = resolvePlaywright(args.project);
177
+ const browser = await chromium.launch({ headless: !args.headed });
178
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
179
+ const page = await ctx.newPage();
180
+
181
+ // ── 登录(验证码 canvas 读取 + 滑块绕过)
182
+ await page.goto(`${args.url}/#/login`, { waitUntil: "networkidle" }).catch(() => {});
183
+ await page.waitForTimeout(1500);
184
+ try {
185
+ const inputs = page.locator(".login-panel input, input[type='text'], input[type='password']");
186
+ const n = await inputs.count();
187
+ if (n >= 2) {
188
+ await inputs.nth(0).fill("admin");
189
+ await inputs.nth(1).fill("admin123");
190
+ }
191
+ // 验证码:从 canvas 关联的 imgCode 读取
192
+ const code = await page.evaluate(() => {
193
+ const canvases = document.querySelectorAll("canvas");
194
+ for (const c of canvases) {
195
+ let el = c;
196
+ while (el) {
197
+ const inst = el.__vueParentComponent;
198
+ const imgCode = inst?.setupState?.imgCode ?? inst?.ctx?.imgCode;
199
+ if (typeof imgCode === "string" && imgCode.length >= 4) return imgCode;
200
+ el = el.parentElement;
201
+ }
202
+ }
203
+ return "";
204
+ });
205
+ if (code) {
206
+ const codeInput = page.locator("input[placeholder*='验证码'], input[placeholder*='密码'] ~ div input").first();
207
+ if (await codeInput.count()) await codeInput.fill(code);
208
+ }
209
+ // 滑块验证绕过
210
+ await page.evaluate(() => {
211
+ let el = document.querySelector(".verifybox, .login-panel, #login, .login-container");
212
+ while (el) {
213
+ const inst = el.__vueParentComponent;
214
+ if (inst?.setupState?.success) { inst.setupState.success(); return true; }
215
+ let c = inst;
216
+ while (c) { if (c.setupState?.success) { c.setupState.success(); return true; } c = c.parent; }
217
+ el = el.parentElement;
218
+ }
219
+ return false;
220
+ });
221
+ const loginBtn = page.locator("button:has-text('登 录'), button:has-text('登录')").first();
222
+ if (await loginBtn.count()) await loginBtn.click();
223
+ await page.waitForTimeout(3500);
224
+ } catch (e) {
225
+ console.log("登录步骤异常(继续尝试直接访问):", e.message);
226
+ }
227
+
228
+ // ── 目标页
229
+ await page.goto(`${args.url}${args.route}`, { waitUntil: "networkidle" }).catch(() => {});
230
+ await page.waitForTimeout(2500);
231
+ // 清主题缓存重载(确保新令牌生效)——⚠️ 只清主题键:responsive-token 是登录态,误清会被路由守卫踢回登录页
232
+ await page.evaluate(() => {
233
+ for (const k of ["responsive-layout", "responsive-configure"]) localStorage.removeItem(k);
234
+ });
235
+ await page.reload({ waitUntil: "networkidle" });
236
+ await page.waitForTimeout(2500);
237
+ // 被踢回登录页则重试一次登录
238
+ if (page.url().includes("/login")) {
239
+ await page.evaluate(async () => {
240
+ const inputs = document.querySelectorAll("input");
241
+ const setVal = (el, v) => { const s = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set; s.call(el, v); el.dispatchEvent(new Event("input", { bubbles: true })); };
242
+ for (const i of inputs) { if (i.type === "text" && !i.value) setVal(i, "admin"); if (i.type === "password") setVal(i, "admin123"); }
243
+ let code = "";
244
+ for (const c of document.querySelectorAll("canvas")) { let el = c; while (el) { const ic = el.__vueParentComponent?.setupState?.imgCode; if (typeof ic === "string" && ic.length >= 3) { code = ic; break; } el = el.parentElement; } if (code) break; }
245
+ for (const i of inputs) { if (i.placeholder && /验证码/.test(i.placeholder) && code) setVal(i, code); }
246
+ let el = document.querySelector(".verifybox, .login-container, #login, .login-panel");
247
+ while (el) { const s = el.__vueParentComponent?.setupState; if (s?.success) { s.success(); break; } el = el.parentElement; }
248
+ });
249
+ const loginBtn = page.locator("button:has-text('登 录'), button:has-text('登录')").first();
250
+ try { if (await loginBtn.count()) await loginBtn.click({ timeout: 5000 }); } catch {}
251
+ await page.waitForTimeout(3000);
252
+ await page.goto(`${args.url}${args.route}`, { waitUntil: "networkidle" }).catch(() => {});
253
+ await page.waitForTimeout(2500);
254
+ }
255
+
256
+ await page.screenshot({ path: path.join(args.project, args.outDir, "verify-landing.png"), fullPage: true });
257
+
258
+ // ── 逐点比对
259
+ const PROPS_HOST = new Set(["background-color", "color", "border-radius", "height", "padding", "gap", "font-size", "font-weight", "border-bottom", "border-top", "text-align", "white-space", "overflow", "box-shadow", "border", "border-color", "line-height", "opacity", "display", "justify-content", "align-items", "min-width", "width", "margin"]);
260
+ const results = [];
261
+ let pass = 0, fail = 0, skip = 0;
262
+
263
+ for (const [roleName, role] of Object.entries(contract.roles || {})) {
264
+ const cls = expectedClassName(roleName);
265
+ const checks = [];
266
+ for (const d of role.host || []) checks.push({ sel: `.${cls}`, prop: d.prop, value: resolveToken(d.value), layer: "host" });
267
+ const innerTarget = (adapter.innerTargets || {})[cls.replace(/^role-/, "")] || "default";
268
+ for (const d of role.inner || []) {
269
+ const sel = innerTarget === "default" ? `.${cls}` : `.${cls} ${innerTarget.split(",").pop().trim()}`;
270
+ checks.push({ sel, prop: d.prop, value: resolveToken(d.value), layer: "inner" });
271
+ }
272
+ if (!checks.length) continue;
273
+
274
+ const found = await page.evaluate((sel) => {
275
+ const els = document.querySelectorAll(sel);
276
+ return els.length;
277
+ }, checks[0].sel).catch(() => 0);
278
+
279
+ if (!found) { skip++; results.push({ role: roleName, status: "SKIP", reason: `选择器 ${checks[0].sel} 在页面上不存在(可能未挂载/未滚动到/变体类未启用)` }); continue; }
280
+
281
+ for (const c of checks) {
282
+ // 跳过复合 border(EP 拆四边)与简写 padding(读四边均值复杂)——由具体边属性代替
283
+ if (c.prop === "border" && !c.value.startsWith("none")) { skip++; continue; }
284
+ if (c.prop === "padding") { skip++; continue; }
285
+ if (c.prop === "gap" && !["flex", "inline-flex", "grid"].includes("")) { /* gap 可读 */ }
286
+ const actual = await page.evaluate(({ sel, prop, cls }) => {
287
+ // 基础角色比对:排除变体元素;禁用实例的外观由 disabled 态决定(图上未画出则不核),一律排除
288
+ let els = [...document.querySelectorAll(sel)].filter((e) =>
289
+ ![...e.classList].some((c) => c.startsWith(cls + "--")));
290
+ els = els.filter((e) => !e.disabled && !e.classList.contains("is-disabled"));
291
+ // table.cell.* 角色:EP 会把 cell 类同步挂到 th——数据单元格断言优先取 td
292
+ if (cls.startsWith("role-table-cell")) {
293
+ const tds = els.filter((e) => e.tagName === "TD");
294
+ if (tds.length) els = tds;
295
+ }
296
+ if (!els.length) return { none: true };
297
+ const key = prop.replace(/-([a-z])/g, (m, x) => x.toUpperCase());
298
+ const first = { value: null, tag: els[0].tagName.toLowerCase(), cls: String(els[0].className).slice(0, 120), txt: (els[0].textContent || "").trim().slice(0, 24), values: [] };
299
+ for (const el of els) {
300
+ const cs = getComputedStyle(el);
301
+ const v = cs[key] ?? null;
302
+ if (v == null) continue;
303
+ if (first.value == null) first.value = v;
304
+ first.values.push(v);
305
+ }
306
+ return first;
307
+ }, { sel: c.sel, prop: c.prop, cls });
308
+ if (!actual || actual.none) { skip++; if (c === checks[0]) results.push({ role: roleName, status: "SKIP", reason: `${cls} 仅存在禁用/变体实例,基态不可断言` }); continue; }
309
+ if (actual.value == null) { skip++; continue; }
310
+ const candidates = actual.values || [actual.value];
311
+ let ok = false;
312
+ for (const cv of candidates) {
313
+ const m = valuesMatch(c.prop, c.value, cv);
314
+ if (m) { ok = true; actual.value = cv; break; }
315
+ }
316
+ if (ok) pass++;
317
+ else { fail++; results.push({ role: roleName, status: "FAIL", sel: c.sel, prop: c.prop, expected: c.value, actual: actual.value, el: `<${actual.tag} class="${actual.cls}"> "${actual.txt}"` }); }
318
+ }
319
+ }
320
+
321
+ // ── 第二遍:visualSanity 通用断言(UX 合理性,规则来自 adapter.visualSanity)
322
+ const sanity = adapter.visualSanity || {};
323
+ const sanityResults = [];
324
+ let sanityPass = 0, sanityFail = 0;
325
+
326
+ const densityHeight = (() => {
327
+ const cell = (contract.roles || {})["table.cell"];
328
+ const v = (cell?.variants || []).find((x) => x.name === "density_loose") || (cell?.variants || [])[0];
329
+ const h = v?.host?.find((d) => d.prop === "height")?.value || cell?.host?.find((d) => d.prop === "height")?.value;
330
+ return h ? parseFloat(h) : null;
331
+ })();
332
+
333
+ if (sanity.noWrap) {
334
+ for (const sel of sanity.noWrap.selectors) {
335
+ const bad = await page.evaluate(({ sel }) => {
336
+ const out = [];
337
+ for (const el of document.querySelectorAll(sel)) {
338
+ const cs = getComputedStyle(el);
339
+ // 真实溢出(横向截断)才判 FAIL;定高盒(标签/按钮)不因高度误判
340
+ if (el.scrollWidth > el.clientWidth + 1) {
341
+ out.push({ cls: String(el.className).slice(0, 60), txt: (el.textContent || "").trim().slice(0, 20), sw: el.scrollWidth, cw: el.clientWidth, ws: cs.whiteSpace });
342
+ }
343
+ }
344
+ return out;
345
+ }, { sel });
346
+ if (bad.length) { sanityFail++; sanityResults.push({ check: "no-wrap", sel, detail: bad.slice(0, 3) }); }
347
+ else sanityPass++;
348
+ }
349
+ }
350
+ if (sanity.noEllipsis) {
351
+ for (const sel of sanity.noEllipsis.selectors) {
352
+ const bad = await page.evaluate(({ sel }) => {
353
+ const out = [];
354
+ for (const el of document.querySelectorAll(sel)) {
355
+ const cs = getComputedStyle(el);
356
+ if (cs.textOverflow === "ellipsis" && el.scrollWidth > el.clientWidth + 1) {
357
+ out.push({ cls: String(el.className).slice(0, 60), txt: (el.textContent || "").trim().slice(0, 20), sw: el.scrollWidth, cw: el.clientWidth });
358
+ }
359
+ }
360
+ return out;
361
+ }, { sel });
362
+ if (bad.length) { sanityFail++; sanityResults.push({ check: "no-ellipsis", sel, detail: bad.slice(0, 3) }); }
363
+ else sanityPass++;
364
+ }
365
+ }
366
+ if (sanity.fullVisibility) {
367
+ for (const sel of sanity.fullVisibility.selectors) {
368
+ const found = await page.evaluate(({ sel }) => {
369
+ for (const el of document.querySelectorAll(sel)) {
370
+ const r = el.getBoundingClientRect();
371
+ const cs = getComputedStyle(el);
372
+ if (cs.display !== "none" && cs.visibility !== "hidden" && r.width > 0 && r.height > 0) return true;
373
+ }
374
+ return false;
375
+ }, { sel });
376
+ if (!found) { sanityFail++; sanityResults.push({ check: "full-visibility", sel, detail: "无可见实例(缺失或被隐藏)" }); }
377
+ else sanityPass++;
378
+ }
379
+ }
380
+ if (sanity.paddingSanity) {
381
+ for (const sel of sanity.paddingSanity.selectors) {
382
+ const bad = await page.evaluate(({ sel }) => {
383
+ const out = [];
384
+ for (const el of document.querySelectorAll(sel)) {
385
+ const cs = getComputedStyle(el);
386
+ const pl = parseFloat(cs.paddingLeft) || 0, pr = parseFloat(cs.paddingRight) || 0;
387
+ if (pl < 4 && pr < 4) out.push({ cls: String(el.className).slice(0, 60), pl, pr });
388
+ }
389
+ return out;
390
+ }, { sel });
391
+ if (bad.length) { sanityFail++; sanityResults.push({ check: "padding-sanity", sel, detail: bad.slice(0, 3) }); }
392
+ else sanityPass++;
393
+ }
394
+ }
395
+ if (sanity.rowHeightSanity) {
396
+ // 目标 = 当前密度档(表格根 density-* 类)对应契约值;无类则取契约 table.cell 基础 height
397
+ const bad = await page.evaluate(({ sel }) => {
398
+ const table = document.querySelector('[class*="density-"]') || document.querySelector('.el-table');
399
+ const cls = table ? [...table.classList].find((c) => c.startsWith("density-")) : null;
400
+ const out = [];
401
+ const els = document.querySelectorAll(sel);
402
+ for (const el of els) {
403
+ const h = el.getBoundingClientRect().height;
404
+ out.push({ h: Math.round(h), density: cls || "(default)" });
405
+ }
406
+ return out;
407
+ }, { sel: sanity.rowHeightSanity.selectors[0] });
408
+ // 与契约档位表比对(density-loose 55 / normal 47 / compact 39)
409
+ const TIER = { "density-loose": 55, "density-normal": 47, "density-compact": 39 };
410
+ const cur = bad[0]?.density;
411
+ const target = TIER[cur];
412
+ const off = target ? bad.filter((b) => Math.abs(b.h - target) > 2) : [];
413
+ if (off.length) { sanityFail++; sanityResults.push({ check: "row-height-sanity", sel: sanity.rowHeightSanity.selectors[0], detail: `当前档 ${cur} 目标 ${target}px,偏差: ${JSON.stringify(off.slice(0, 3))}` }); }
414
+ else sanityPass++;
415
+ }
416
+ if (sanity.chipHeight) {
417
+ // 定高小件:从契约对应 role 取声明 height,比对实际 rect.height(换行撑高检测)
418
+ const chipTargets = {};
419
+ const roleOf = { ".role-tag-status": ["tag.status", "24"], ".role-shell-tabs-close": ["shell.tabs.close", "12"] };
420
+ for (const [sel, [role, fb]] of Object.entries(roleOf)) {
421
+ const r = (contract.roles || {})[role];
422
+ const h = r?.host?.find((d) => d.prop === "height")?.value || fb;
423
+ chipTargets[sel] = parseFloat(h) || 24;
424
+ }
425
+ for (const sel of sanity.chipHeight.selectors) {
426
+ const bad = await page.evaluate(({ sel, target }) => {
427
+ const out = [];
428
+ for (const el of document.querySelectorAll(sel)) {
429
+ const h = el.getBoundingClientRect().height;
430
+ if (Math.abs(h - target) > 2) out.push({ h: Math.round(h), target, txt: (el.textContent || "").trim().slice(0, 12) });
431
+ }
432
+ return out;
433
+ }, { sel, target: chipTargets[sel] || 24 });
434
+ if (bad.length) { sanityFail++; sanityResults.push({ check: "chip-height", sel, detail: `目标 ${chipTargets[sel]}px,换行/撑高: ${JSON.stringify(bad.slice(0, 3))}` }); }
435
+ else sanityPass++;
436
+ }
437
+ }
438
+ if (sanity.tabFlat) {
439
+ for (const sel of sanity.tabFlat.selectors) {
440
+ const bad = await page.evaluate(({ sel }) => {
441
+ const out = [];
442
+ for (const el of document.querySelectorAll(sel)) {
443
+ const cs = getComputedStyle(el);
444
+ const bw = parseFloat(cs.borderTopWidth) || 0;
445
+ if (bw > 0 || cs.outlineStyle !== "none" || cs.boxShadow !== "none") {
446
+ out.push({ border: cs.borderTopWidth, outline: cs.outlineStyle, shadow: cs.boxShadow.slice(0, 30), txt: (el.textContent || "").trim().slice(0, 10) });
447
+ }
448
+ }
449
+ return out;
450
+ }, { sel });
451
+ if (bad.length) { sanityFail++; sanityResults.push({ check: "tab-flat", sel, detail: JSON.stringify(bad.slice(0, 3)) }); }
452
+ else sanityPass++;
453
+ }
454
+ }
455
+ if (sanity.wrapperPadding) {
456
+ for (const sel of sanity.wrapperPadding.selectors) {
457
+ const bad = await page.evaluate(({ sel, expect }) => {
458
+ const out = [];
459
+ for (const el of document.querySelectorAll(sel)) {
460
+ const cs = getComputedStyle(el);
461
+ const pl = parseFloat(cs.paddingLeft) || 0, pr = parseFloat(cs.paddingRight) || 0;
462
+ if (Math.abs(pl - expect) > 2 || Math.abs(pr - expect) > 2) out.push({ pl, pr, expect });
463
+ }
464
+ return out;
465
+ }, { sel, expect: sanity.wrapperPadding.expectPx || 20 });
466
+ if (bad.length) { sanityFail++; sanityResults.push({ check: "wrapper-padding", sel, detail: JSON.stringify(bad.slice(0, 3)) }); }
467
+ else sanityPass++;
468
+ }
469
+ }
470
+ if (sanity.gapGeometry) {
471
+ for (const g of sanity.gapGeometry.groups) {
472
+ const bad = await page.evaluate(({ container, itemSel, expectGap, tol }) => {
473
+ const el = document.querySelector(container);
474
+ if (!el) return ["容器不存在"];
475
+ const items = (itemSel === "> *" ? [...el.children] : [...el.querySelectorAll(itemSel)]).filter((e) => e.getBoundingClientRect().width > 0);
476
+ if (items.length < 2) return [];
477
+ const gaps = [];
478
+ for (let i = 1; i < items.length; i++) {
479
+ const prev = items[i - 1].getBoundingClientRect();
480
+ const cur = items[i].getBoundingClientRect();
481
+ const gap = Math.round((cur.left - prev.right) * 10) / 10;
482
+ if (Math.abs(gap - expectGap) > tol) gaps.push({ pair: i, gap, expectGap });
483
+ }
484
+ return gaps;
485
+ }, { container: g.container, itemSel: g.itemSel, expectGap: g.expectGap, tol: g.tol ?? 2 });
486
+ if (bad.length) { sanityFail++; sanityResults.push({ check: `gap-geometry:${g.name}`, sel: g.container, detail: JSON.stringify(bad.slice(0, 3)) }); }
487
+ else sanityPass++;
488
+ }
489
+ }
490
+ if (sanity.headerAlign) {
491
+ for (const h of sanity.headerAlign) {
492
+ const bad = await page.evaluate(({ sel, expect }) => {
493
+ const el = document.querySelector(sel);
494
+ if (!el) return ["not found"];
495
+ const cs = getComputedStyle(el);
496
+ const ta = cs.textAlign;
497
+ if (ta === expect) return [];
498
+ // flex 容器下 text-align 不生效,看 justify-content
499
+ if (cs.display.includes("flex")) {
500
+ const jc = cs.justifyContent;
501
+ if (expect === "center" && jc === "center") return [];
502
+ if (expect === "right" && (jc === "flex-end" || jc === "right" || jc === "end")) return [];
503
+ }
504
+ return [{ ta, jc: cs.justifyContent, expect }];
505
+ }, { sel: h.sel, expect: h.expect });
506
+ if (bad.length) { sanityFail++; sanityResults.push({ check: `header-align:${h.name}`, sel: h.sel, detail: JSON.stringify(bad) }); }
507
+ else sanityPass++;
508
+ }
509
+ }
510
+
511
+ // ── 报告
512
+ console.log("\n渲染取证报告(期望源 = 契约 JSON)");
513
+ console.log("=".repeat(70));
514
+ for (const r of results) {
515
+ if (r.status === "SKIP") console.log(`[SKIP] ${r.role}: ${r.reason}`);
516
+ else console.log(`[FAIL] ${r.role} · ${r.sel} · ${r.prop}\n 期望 ${r.expected}\n 实际 ${r.actual}\n 元素 ${r.el}`);
517
+ }
518
+ console.log("-".repeat(70));
519
+ console.log(`第一遍(契约逐点): 命中 ${pass} · 差异 ${fail} · 跳过 ${skip}(简写/缺元素)`);
520
+ console.log(`第二遍(visualSanity 通用断言): 通过 ${sanityPass} · 失败 ${sanityFail}`);
521
+ for (const s of sanityResults) {
522
+ console.log(`[SANITY-FAIL] ${s.check} · ${s.sel}`);
523
+ console.log(` ${typeof s.detail === "string" ? s.detail : JSON.stringify(s.detail)}`);
524
+ }
525
+ console.log(`截图: ${path.join(args.project, args.outDir, "verify-landing.png")}`);
526
+ await browser.close();
527
+ process.exit(fail > 0 || sanityFail > 0 ? 1 : 0);
528
+ }
529
+
530
+ main().catch((e) => { console.error(e); process.exit(1); });