#!/usr/bin/env node /** * scripts/verify-visual-v13.mjs —— v13 收尾三项验收(每项打印实测原值,不看「通过」两个字)。 * * node scripts/verify-visual-v13.mjs --tag before 只打印、不判分(改前基线) * node scripts/verify-visual-v13.mjs 判分:任一项不达标 exit 1 * --json out.json 结果落盘(docs 里的数字从这里抄) * * 量什么: * 一、排序段(/pool .seg,桌面;手机上打开 .pbar-more 里的 .pbar-sorts) * - 选中项 computed background 与文字色、aria-pressed 计数 * - 选中 / 未选中 font-size、line-height、按钮框高 * - 外层:border-radius / border-width;按钮:每项各自 borderRadius = 0、相邻之间 1px 分隔线 * 二、机会卡描述(/pool 每张 kf-opportunity-card) * - .one 是否有文字、一行字高(line-height)、卡内布局方式 * - 无描述卡是否「没有描述区」而不是空 div;指标区上提了多少 * - 有描述卡 / 无描述卡「内容总高」差;每张卡最大连续空白(叶子文字的竖直间隙) * 三、/pool 页脚类名命中:拿 cardFooter() 的每个类名,在 /pool 页脚里查规则命中(CSSOM 遍历 shadow root 的 adoptedStyleSheets), * 再与 /reports 同名元素逐项比对 computed padding / border / borderRadius / 字号 / 颜色。 * * 坑(见 memory kf-verify-browser-quirks):视图异步挂载;跨 shadow 走 host;hover 要真实指针;滚动位置先归零。 */ import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const argv = process.argv.slice(2); const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d); const TAG = arg('--tag', 'after'); const JUDGE = TAG !== 'before'; const JSON_OUT = arg('--json', ''); if (!process.env.CHROME_PATH) { const pinned = '/opt/hermes/.playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell'; if (existsSync(pinned)) process.env.CHROME_PATH = pinned; } const { startServer, launch, DEEP } = await import('./v11b-lib.mjs'); const srv = await startServer(Number(process.env.VERIFY_PORT || 4194)); const browser = await launch(); const errors = []; const newPage = async (width = 1440, height = 900) => { const ctx = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1 }); const page = await ctx.newPage(); page.on('pageerror', (e) => errors.push(e.message)); page.on('console', (m) => { if (m.type() === 'error' && !/favicon/.test(m.text())) errors.push(m.text()); }); return page; }; const page = await newPage(); const gotoView = async (p, name) => { await page.goto(new URL(p, srv.base).href, { waitUntil: 'networkidle' }); await page.waitForFunction((n) => { const v = document.querySelector('kf-app')?.shadowRoot?.querySelector(n); return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0 && ('db' in v ? v.db != null : true); }, name, { timeout: 20000 }); await page.waitForTimeout(700); }; const rgb = (s) => (s.match(/\d+(\.\d+)?/g) || []).map(Number); const hex = (rgba, under = [255, 255, 255]) => { /* rgba → 叠在不透明底上的实际颜色(选中态是半透明底,亮度要算合成后的) */ const [r, g, b, a = 1] = rgba; return [0, 1, 2].map((i) => Math.round(rgba[i] * a + under[i] * (1 - a))); }; const lum = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b; const out = { tag: TAG, at: new Date().toISOString(), one: null, two: null, three: null }; /* ======================= 一、排序段 ======================= */ { await gotoView('/pool', 'view-pool'); out.one = await page.evaluate(`(async () => { ${DEEP} const v = appR().querySelector('view-pool').shadowRoot; const seg = v.querySelector('.filters .seg'); if (!seg) return { missing: true }; const btns = [...seg.querySelectorAll('button')]; const read = (b) => { const s = getComputedStyle(b); const r = b.getBoundingClientRect(); return { text: b.textContent.trim(), pressed: b.getAttribute('aria-pressed'), bg: s.backgroundColor, color: s.color, fontSize: s.fontSize, lineHeight: s.lineHeight, h: Math.round(r.height * 10) / 10, radius: s.borderTopLeftRadius + ' ' + s.borderTopRightRadius, borderLeft: s.borderLeftWidth }; }; const ss = getComputedStyle(seg); /* 分隔线:相邻按钮「左 1px」+ 第一个「无左边框」 */ const seps = btns.map((b, i) => ({ i, left: getComputedStyle(b).borderLeftWidth, leftStyle: getComputedStyle(b).borderLeftStyle, leftColor: getComputedStyle(b).borderLeftColor })); /* 实际命中规则里的 background(不是 computed 的 rgba(0,0,0,0) 兜底) */ const sel = btns.find((b) => b.getAttribute('aria-pressed') === 'true'); const selRule = sel ? getComputedStyle(sel) : null; const wrap = seg.parentElement.getBoundingClientRect(); const r = seg.getBoundingClientRect(); return { btns: btns.map(read), seps, outer: { radius: ss.borderTopLeftRadius, border: ss.borderTopWidth + ' ' + ss.borderTopStyle + ' ' + ss.borderTopColor, h: Math.round(r.height * 10) / 10, bg: ss.backgroundColor }, selected: sel ? read(sel) : null, unselected: btns.filter((b) => b.getAttribute('aria-pressed') !== 'true').map(read), selColorRule: selRule ? selRule.color : null, wrapTop: wrap.top }; })()`); /* 手机档:把排序抽屉打开,量里面的段选 */ const mpage = await newPage(393, 852); await mpage.goto(new URL('/pool', srv.base).href, { waitUntil: 'networkidle' }); await mpage.waitForFunction(() => { const v = document.querySelector('kf-app')?.shadowRoot?.querySelector('view-pool'); return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0 && v.db != null; }, null, { timeout: 20000 }); await mpage.waitForTimeout(600); await mpage.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('view-pool'); v.shadowRoot.querySelector('.pbar-more').setAttribute('open', ''); }); await mpage.waitForTimeout(200); out.one.mobile = await mpage.evaluate(() => { const v = document.querySelector('kf-app').shadowRoot.querySelector('view-pool').shadowRoot; const btns = [...v.querySelectorAll('.pbar-sorts button')]; const sel = btns.find((b) => b.getAttribute('aria-pressed') === 'true'); const s = sel ? getComputedStyle(sel) : null; return sel ? { text: sel.textContent.trim(), bg: s.backgroundColor, color: s.color, fontSize: s.fontSize, lineHeight: s.lineHeight } : { missing: true }; }); await mpage.context().close(); } /* ======================= 二、报告卡描述(reportCard 的 .lead) ======================= 量 /reports 与 /stages 的每一张 .rc: - 有无描述(.lead 元素存在且有文字) - 标题 → 指标区(.m)之间的缝:有描述的卡把这条缝摊给四张卡(flex 平均分), 无描述的卡应把同一条缝均分到卡内各条缝 —— 不再是 66px 一条空缝 - 内容总高 = .rc 内第一个可见子块顶 → 最后一个子块底 - 最大连续空白 = .rc 内叶子文字矩形按 top 排,相邻两块之间竖直间隙的最大值 */ { out.two = { pages: [] }; for (const [p, name] of [['/reports', 'view-reports'], ['/stages', 'view-stages']]) { await gotoView(p, name); const r = await page.evaluate(`(async () => { ${DEEP} const root = appR().querySelector('${name}').shadowRoot; const cards = [...root.querySelectorAll(".rc")].filter((c) => c.getBoundingClientRect().height > 0 && c.getBoundingClientRect().width > 0); const cardsInfo = cards.map((c) => { const lead = c.querySelector('.lead'); const tk = c.querySelector('h3').getBoundingClientRect(); const m = c.querySelector('.m').getBoundingClientRect(); /* 内容块 = 含文字的块级子块(空 / 装饰子块不算内容);首块顶到末块底 */ const kids = [...c.querySelectorAll('*')].filter((e) => { if (![...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) return false; return e.parentElement === c || !/^(inline|contents)/.test(getComputedStyle(e).display); }); const first = kids[0].getBoundingClientRect(), last = kids[kids.length - 1].getBoundingClientRect(); /* 叶子文字块:先取最内层的文字元素(祖先容器不重复计,否则父盒扣掉 padding 后会造出假空白)。 display:contents(.src 在手机档里的 .l1/.l2)没有盒,跳过。 */ const textAll = [...c.querySelectorAll('*')].filter((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())); const inner = textAll.filter((e) => !textAll.some((o) => o !== e && e.contains(o))); /* 最大连续空白:把每块文字矩形按 left 夹到它最内层块级祖先的内容盒里(中文落点分散、首字左对齐, 光比 left/right 会误判并排),再按 top 排序;前块在 [lo, hi] 覆盖带之上时计一次空隙。 */ const blockAncestor = (e) => { for (let q = e.parentElement; q && q !== c; q = q.parentElement) { const d = getComputedStyle(q).display; if (/^(block|flex|grid|list-item|flow-root|table)$/.test(d)) return q; } return c; }; const boxes = inner.map((e) => { const r = e.getBoundingClientRect(); const q = blockAncestor(e); const qr = q.getBoundingClientRect(); const cs = getComputedStyle(q); const l = Math.max(r.left, qr.left + parseFloat(cs.paddingLeft || 0)); const rr = Math.min(r.right, qr.right - parseFloat(cs.paddingRight || 0)); return { left: Math.min(l, rr), right: Math.max(l, rr), top: Math.round(r.top), bottom: Math.round(r.bottom) }; }) .filter((r) => r.bottom > r.top).sort((a, b) => a.top - b.top); let maxGap = 0, gapAt = '', lo = null, hi = null; for (const r of boxes) { if (hi == null) { lo = r.top; hi = r.bottom; continue; } if (r.bottom <= lo) { const g = lo - r.bottom; if (g > maxGap) { maxGap = g; gapAt = lo ? (r.bottom + '→' + lo) : ''; } } lo = Math.min(lo, r.top); hi = Math.max(hi, r.bottom); } /* 卡内每条缝(相邻子块之间),以及每块子块的 stretch 情况 */ const gaps = []; for (let i = 1; i < kids.length; i++) gaps.push(Math.round(kids[i].getBoundingClientRect().top - kids[i - 1].getBoundingClientRect().bottom)); return { title: (c.querySelector('h3').textContent || '').trim().slice(0, 14), lead: lead ? lead.textContent.trim().slice(0, 12) : null, leadTxt: lead ? lead.textContent.trim() : '', gapTitleToM: Math.round(m.top - tk.bottom), contentH: Math.round(last.bottom - first.top), rcH: Math.round(c.getBoundingClientRect().height), faceH: Math.round(c.closest('kf-glass-card').shadowRoot.querySelector('.face').getBoundingClientRect().height), cardH: Math.round(c.closest('kf-glass-card').getBoundingClientRect().height), maxGap, gapAt, rowIdx: Math.round(c.getBoundingClientRect().top) }; }); return { n: cardsInfo.length, cards: cardsInfo }; })()`); /* 同排(top 相同)卡片的等高由 v12 脚本管;这里只记 */ out.two.pages.push({ page: p, ...r }); } } /* ======================= 三、页脚类名命中 ======================= */ const FOOT_PROBE = `(async () => { ${DEEP} const host = appR().querySelector(location.pathname.includes('reports') ? 'view-reports' : 'view-pool'); const sr = host.shadowRoot; const foot = sr.querySelector('.cfoot[data-foot]'); if (!foot) return { missing: true, host: host.localName }; /* 1. 页脚里出现的每个类名,逐项查:在宿主 shadow root 的 adoptedStyleSheets 里,是否有规则的选择器能命中它 */ const sheets = [...sr.adoptedStyleSheets, ...[...sr.querySelectorAll('style')].map((s) => s.sheet)]; const rules = []; const walkRules = (list, media) => { for (const r of list || []) { if (r.cssRules && !r.selectorText) { walkRules(r.cssRules, r.conditionText || media); continue; } if (r.selectorText) rules.push({ sel: r.selectorText, media: media || '', css: r.style.cssText.slice(0, 90) }); } }; for (const s of sheets) if (s) { try { walkRules(s.cssRules, ''); } catch { /* 跨源 */ } } const classes = new Set(); for (const el of [foot, ...foot.querySelectorAll('*')]) { const cn = typeof el.className === 'string' ? el.className : ''; /* svg 的 className 是 SVGAnimatedString */ for (const c of cn.split(/\\s+/).filter(Boolean)) classes.add((el.localName === 'footer' || !/^(svg|path|circle|line|polyline|rect|g)$/.test(el.localName) ? '' : 'svg:') + c); } const hits = []; for (const c of classes) { const name = c; const matching = rules.filter((r) => { /* 规则选择器里是否以 .name 作为「完整类名」出现(避免 .cfl 命中 .cfleft) */ return new RegExp('\\\\.' + name.replace(/[-]/g, '\\\\-') + '(?![\\\\w-])').test(r.sel); }); /* 选择器是否真的能作用到页脚里的某个元素 */ const el = [foot, ...foot.querySelectorAll('*')].find((e) => { const cn = typeof e.className === 'string' ? e.className : ''; return cn.split(/\\s+/).includes(name); }); const applies = matching.filter((r) => { if (!el) return false; try { return el.matches(r.sel.replace(/::?[a-z-]+(\\([^)]*\\))?/g, '')); } catch { return false; } }); hits.push({ cls: name, tag: el ? elLabel(el).slice(0, 30) : '', rules: matching.length, applies: applies.length, el: el ? elLabel(el).slice(0, 30) : '' }); } /* 2. 逐元素 computed 快照:footer 本身 + 里面每个元素,按「结构路径」记 */ const snap = (f) => { const one = (el) => { const s = getComputedStyle(el); return { padding: [s.paddingTop, s.paddingRight, s.paddingBottom, s.paddingLeft].join(' '), border: [s.borderTopWidth, s.borderRightWidth, s.borderBottomWidth, s.borderLeftWidth].join(' ') + ' / ' + s.borderTopStyle + ' / ' + s.borderTopColor, borders: [s.borderTopWidth + ' ' + s.borderTopStyle, s.borderRightWidth + ' ' + s.borderRightStyle, s.borderBottomWidth + ' ' + s.borderBottomStyle].join('|'), radius: [s.borderTopLeftRadius, s.borderTopRightRadius, s.borderBottomRightRadius, s.borderBottomLeftRadius].join(' '), font: s.fontSize + '/' + s.lineHeight + ' ' + s.fontWeight, color: s.color, bg: s.backgroundColor, display: s.display, gap: s.gap, justify: s.justifyContent, marginTop: s.marginTop }; }; const list = [[f, f.parentElement && f.parentElement.classList.contains('cfoot') ? '' : '']]; const path = (el) => { const p = []; for (let q = el; q && q !== f; q = q.parentElement) p.unshift(q.localName + (typeof q.className === 'string' && q.className ? '.' + q.className.trim().split(/\\s+/).join('.') : '')); return p.join('>'); }; const res = { 'cfoot': one(f) }; /* 同名「元素」只比可共用的外观:内衬 / 边框 / 圆角 / 字体 / 字色 / 底色 / 间距 / 子块对齐。 位置(margin)和行内排布(justify-content)是两页各自的版式不同,不属于页脚「同名元素」的样式。 */ res['cfoot'].marginTop = undefined; delete res['cfoot'].marginTop; delete res['cfoot'].justify; for (const el of f.querySelectorAll('*')) { if (['svg', 'path', 'circle', 'line', 'polyline', 'rect', 'g'].includes(el.localName)) continue; res[path(el)] = one(el); } return res; }; /* 两页页脚根节点不同(/reports 是