| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627 |
- #!/usr/bin/env node
- /**
- * scripts/verify-ui-v10.mjs —— v10「修布局空洞 + 审美升级」验收(任务书第 4 节 16 项,全部实测值)。
- *
- * 分组:
- * 1–4 布局:.grid2 align-items / 两张卡高度−内容高度 / 全页最大连续空白 / 剩余 stretch
- * 5–8 排版:主数值 computed / 标签 computed / 全站字号档位 / 全站字重档位
- * 9–12 视觉:阴影档数 / 玻璃内高光 + saturate / 卡片圆角 / 强调色占比
- * 13–14 微交互:hover transition / prefers-reduced-motion
- * 15–16 回归:手机 393(Tab 栏 / 单列 / 无横向滚动)/ 46 单测 + check + 字数
- *
- * 用法:node scripts/verify-ui-v10.mjs [--shots docs/screenshots/v10] [--tag before|after]
- * --tag before 只截图不判分(修前基线);--tag after(默认)判分。
- */
- import { mkdirSync, existsSync } from 'node:fs';
- import { execFileSync, spawn } from 'node:child_process';
- 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 SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v10'));
- mkdirSync(SHOTS, { recursive: true });
- const rows = [];
- const check = (no, name, ok, detail) => {
- rows.push({ no, name, ok: Boolean(ok), detail });
- console.log(`${ok ? '✓' : '✗'} ${no}. ${name}\n ${detail}`);
- };
- async function loadPlaywright() {
- for (const c of [join(ROOT, 'node_modules/playwright-core'), '/opt/data/work/kangshifu/testing/node_modules/playwright-core']) {
- if (existsSync(c)) return (await import(`${c}/index.js`)).default ?? (await import(`${c}/index.js`));
- }
- return import('playwright');
- }
- let server = null;
- let base = arg('--url', '');
- if (!base) {
- const { writeConfig } = await import('./config.mjs');
- const { prefix } = writeConfig({ env: 'local' });
- const port = Number(process.env.VERIFY_PORT || 4180);
- server = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' });
- await new Promise((r) => setTimeout(r, 700));
- base = `http://127.0.0.1:${port}${prefix}`;
- }
- process.on('exit', () => server?.kill());
- const pw = await loadPlaywright();
- const browser = await pw.chromium.launch({
- executablePath: process.env.CHROME_PATH || process.env.AGENT_BROWSER_EXECUTABLE_PATH || undefined,
- args: ['--no-sandbox', '--disable-dev-shm-usage'],
- });
- const url = (p) => new URL(p, base).href;
- const ctxD = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
- const D = await ctxD.newPage();
- const ctxM = await browser.newContext({
- viewport: { width: 393, height: 852 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true,
- userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
- });
- const M = await ctxM.newPage();
- const errors = [];
- for (const p of [M, D]) {
- p.on('pageerror', (e) => errors.push(e.message));
- p.on('console', (m) => { if (m.type() === 'error' && !/favicon/.test(m.text())) errors.push(m.text()); });
- }
- const goto = async (page, p, ms = 1300) => { await page.goto(url(p), { waitUntil: 'networkidle' }); await page.waitForTimeout(ms); };
- const shot = (page, name, opts = {}) => page.screenshot({ path: join(SHOTS, name), animations: 'disabled', timeout: 15000, ...opts });
- const DEEP = `
- const walk = (root, fn) => { for (const el of root.querySelectorAll('*')) { fn(el); if (el.shadowRoot) walk(el.shadowRoot, fn); } };
- const deepAll = (fn) => walk(document, fn);
- const elLabel = (el) => el.localName + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/ +/).join('.') : '');
- const visible = (el) => { const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) return false; for (let q = el; q; q = q.parentElement || q.getRootNode()?.host) { const s = getComputedStyle(q); if (s.display === 'none' || s.visibility === 'hidden' || Number(s.opacity) < 0.05) return false; } return true; };
- const app = () => document.querySelector('kf-app');
- const appR = () => app().shadowRoot;
- `;
- const evalIn = (page, body) => page.evaluate(`(async () => {
- ${DEEP}
- const __qs = (sel) => { const [host, ...rest] = sel.split(' '); const el = appR().querySelector(host); if (!el) return null; const v = el.shadowRoot.querySelector(sel.split(' ').pop());
- if (!v) throw new Error('DIAG 找不到:' + sel + ' → ' + [...el.shadowRoot.querySelectorAll('*')].map((x) => x.localName + '.' + (x.className || '')).join(',').slice(0, 300)); return v; };
- ${body}
- })()`);
- const PAGES = ['/', '/pool', '/stages', '/reports', '/reports/competitor', '/reports/voc', '/members', '/ledger'];
- // 对比度要覆盖「读正文」的页面(报告阅读页 / 抽屉),这些不进布局统计
- const TEXT_PAGES = [...PAGES, '/report/bCld0Iy77e'];
- // 视图元素按路由异步挂载(路由器订阅晚一拍):等它出现再量,否则偶发 null
- const VIEW_OF = { '/report/bCld0Iy77e': 'view-report', '/': 'view-workbench', '/pool': 'view-pool', '/stages': 'view-stages', '/reports': 'view-reports', '/reports/competitor': 'view-report-type', '/reports/voc': 'view-report-type', '/members': 'view-members', '/ledger': 'view-ledger' };
- const gotoView = async (page, p, ms = 1300) => {
- for (let attempt = 0; attempt < 3; attempt++) {
- await goto(page, p, ms + attempt * 800);
- try {
- await page.waitForFunction((name) => {
- const a = document.querySelector('kf-app');
- const v = a?.shadowRoot?.querySelector(name);
- return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0;
- }, VIEW_OF[p] || 'div', { timeout: 8000 });
- await page.waitForTimeout(150);
- return;
- } catch (e) {
- if (attempt === 2) {
- const diag = await page.evaluate(() => {
- const a = document.querySelector('kf-app');
- return { url: location.href, app: !!a, shadow: !!a?.shadowRoot, main: [...(a?.shadowRoot?.querySelector('main#view')?.children || [])].map((x) => x.localName).join(',') };
- });
- throw new Error(`gotoView 等待 ${VIEW_OF[p]} 失败:${JSON.stringify(diag)}`);
- }
- }
- }
- };
- const out = {}; // 采集结果,便于 --tag before/after 对比
- /* ============ 1–2. 工作台 .grid2 ============ */
- await gotoView(D, '/', 1500);
- const g2 = await evalIn(D, `
- const v = appR().querySelector('view-workbench');
- const vr = v.shadowRoot;
- const grid = vr.querySelector('.grid2');
- const cards = [...vr.querySelectorAll('.grid2 > kf-glass-card')];
- const gcs = getComputedStyle(grid);
- return {
- alignItems: gcs.alignItems,
- inlineStyle: grid.getAttribute('style') || '',
- gridH: Math.round(grid.getBoundingClientRect().height),
- cols: gcs.gridTemplateColumns,
- cards: cards.map((c) => {
- const frame = c.shadowRoot.querySelector('.frame');
- const face = c.shadowRoot.querySelector('.face');
- const slot = c.shadowRoot.querySelector('slot');
- const nodes = slot.assignedElements();
- const top = Math.min(...nodes.map((n) => n.getBoundingClientRect().top));
- const bottom = Math.max(...nodes.map((n) => n.getBoundingClientRect().bottom));
- const fcs = getComputedStyle(frame);
- return {
- id: c.textContent.trim().slice(0, 12).replace(/\\s+/g, ' '),
- cardH: +c.getBoundingClientRect().height.toFixed(1),
- contentH: +(bottom - top).toFixed(1),
- faceH: +face.getBoundingClientRect().height.toFixed(1),
- display: fcs.display, justify: fcs.justifyContent, dir: fcs.flexDirection,
- };
- }),
- };
- `);
- out.grid2 = g2;
- const gap12 = g2.cards.map((c) => +(c.cardH - c.contentH).toFixed(1));
- check(1, '.grid2 改为 align-items: start(computed)',
- g2.alignItems === 'start',
- `computed align-items=${g2.alignItems};inline style「${g2.inlineStyle || '无'}」;grid 高 ${g2.gridH}px,列宽 ${g2.cols}`);
- check(2, '两张卡高度 = 各自内容高度(卡高 − 内容高 ≤ 40px)',
- gap12.every((g) => g <= 40),
- g2.cards.map((c, i) => `卡${i + 1}「${c.id}」高 ${c.cardH}px,内容 ${c.contentH}px,差 ${gap12[i]}px(.frame display=${c.display} justify=${c.justify})`).join(';') +
- `;两卡高度差 ${Math.abs(g2.cards[0].cardH - g2.cards[1].cardH).toFixed(1)}px`);
- await shot(D, TAG === 'before' ? 'before-桌面-工作台.png' : '01-桌面-工作台修空白后.png', { fullPage: true });
- // 01b:只看首屏(两张卡 + 四张数据卡),修前修后对比用
- await D.evaluate(() => window.scrollTo(0, 0));
- await D.waitForTimeout(200);
- await shot(D, TAG === 'before' ? 'before-首屏-两张卡.png' : '01b-桌面-首屏.png');
- /* ============ 3. 全页最大连续空白 ============ */
- const maxGap = async (page, p) => {
- await gotoView(page, p, 1400);
- if (process.env.GAP_DEBUG) await page.evaluate('window.__GAP_DEBUG__ = 1');
- return evalIn(page, `
- // 量「正文主列里相邻两行内容之间的垂直空白」。
- // 内容 = 所有带文字 / 图形 / 有底色的可见元素(含卡片整块的底色与发丝边)按垂直投影切成的行。
- // 轨道:有文字的叶子元素(每个文字节点自己的盒子)。桥接:任何涂了东西的元素(背景、边框、图、字),
- // 只要它完整地横跨在两条轨道之间,这段空白就是被它占着的(卡片底、空态面板、表格行都算)。
- const main = appR().querySelector('main#view') || appR().querySelector('.view');
- // 跨 shadow 边界判「在不在主列里」:逐级往上(子 → shadow 宿主 → 父)直到到达 main 或顶
- const inMain = (el) => { for (let n = el; n; n = n.parentElement || n.getRootNode()?.host) { if (n === main) return true; if (n === document || n === app()) return false; } return false; };
- const tracks = [], bridges = [], paintedNow = [];
- deepAll((el) => {
- if (!inMain(el) || !visible(el)) return;
- if (/^(STYLE|SCRIPT|NOSCRIPT|SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|DEFS|USE)$/.test(el.localName)) return;
- const r = el.getBoundingClientRect();
- if (r.height < 1 || r.width < 1) return;
- const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
- const cs = getComputedStyle(el);
- // 只有「看得见的东西」才让空白不算空白:文字自己的盒子、实底色、可读的底色(字在它上面)、图标 / 输入框。
- // 纯透明的布局容器、只靠 ::before 画的装饰不桥接。
- const solidBg = cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && !cs.backgroundColor.endsWith(', 0)');
- const tintBg = cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && Boolean(t);
- const bgPaint = solidBg || tintBg;
- const edgePaint = parseFloat(cs.borderTopWidth) > 0 && cs.borderTopStyle !== 'none' && cs.borderTopColor !== 'rgba(0, 0, 0, 0)';
- const graphic = el.localName === 'svg' || el.localName === 'img' || el.localName === 'input' || el.localName === 'button';
- const rect = { l: r.left, r: r.right, t: r.top + scrollY, b: r.bottom + scrollY };
- if (graphic || bgPaint || edgePaint) bridges.push({ ...rect, text: t.slice(0, 12), why: [graphic && 'graphic', bgPaint && cs.backgroundColor, edgePaint && cs.borderTopColor].filter(Boolean).join('|') });
- // 涂了东西的元素也算「占了这段竖向空间」(空态面板、表格行、卡片内小件)—— 空白只能出现在什么都没画的地方
- if (graphic || bgPaint || edgePaint || t) paintedNow.push({ ...rect });
- if (t && !/^\s*$/.test(t)) tracks.push({ ...rect, text: t.slice(0, 12) });
- });
- // 行切分(top ±4px 同一行)
- tracks.sort((x, y) => x.t - y.t);
- const rows = [];
- for (const p of tracks) {
- const row = rows.find((q) => Math.abs(q.t - p.t) <= 4);
- if (row) { row.b = Math.max(row.b, p.b); row.text ||= p.text; } else rows.push({ t: p.t, b: p.b, text: p.text });
- }
- rows.sort((x, y) => x.t - y.t);
- let worst = { gap: 0 };
- for (let i = 0; i < rows.length; i++) for (let j = i + 1; j < rows.length; j++) {
- const a = rows[i], b = rows[j];
- if (b.t <= a.b) continue; // 两行之间的所有元素都画了东西
- // 夹在两行之间的空白是否「真的空」:在 [a.b, b.t] 里每 2px 取一个横截面,
- // 只要某个横截面上有任意一个可见、涂了东西的元素(卡片底、表格行、空态面板……)穿过,就算「有内容」。
- // (只看纵向并集会被「左列卡片 + 右列卡片错开」误盖住;用横截面采样才不会被别的列骗过。)
- const STEP = 2;
- let big = 0, run = 0;
- for (let y = a.b + 1; y < b.t; y += STEP) {
- const hit = paintedNow.some((c) => y >= c.t && y <= c.b) || rows.some((r) => y >= r.t && y <= r.b);
- if (hit) { run = 0; } else { run += STEP; if (run > big) big = run; }
- }
- const gap = b.t - a.b;
- if (big > worst.gap) worst = { gap: big, span: gap, a: '「' + a.text + '」(@' + Math.round(a.t) + ')', b: '「' + b.text + '」(@' + Math.round(b.t) + ')' };
- }
- if (window.__GAP_DEBUG__) worst.all = (worst.all || []).sort((x, y) => y.gap - x.gap).slice(0, 4);
- return { ...worst, rows: rows.length, tracks: tracks.length, painted: paintedNow.length, docH: Math.round(document.documentElement.scrollHeight) };
- `);
- };
- const gaps = {};
- for (const p of PAGES) gaps[p] = await maxGap(D, p);
- out.gaps = gaps;
- const worstPage = Object.entries(gaps).sort((a, b) => b[1].gap - a[1].gap)[0];
- await gotoView(D, '/', 1400);
- // 口径补充:与工作台同一口径再量一次「两张卡」区域(.grid2 内最后一行文字 → 区块外下一行文字),报出数值
- const grid2Gap = await evalIn(D, `
- const v = appR().querySelector('view-workbench').shadowRoot;
- const g = v.querySelector('.grid2');
- const cards = [...g.querySelectorAll('kf-glass-card')];
- const lastB = Math.max(...cards.map((c) => c.getBoundingClientRect().bottom));
- const hd = v.querySelector('.hd');
- return Math.round(hd.getBoundingClientRect().top + window.scrollY - lastB - window.scrollY) || Math.round(hd.getBoundingClientRect().top - lastB);
- `);
- out.grid2Gap = grid2Gap;
- check(3, '全页最大连续空白 ≤ 80px(9 个路由逐页量;按横截面采样,卡片面 / 表格行 / 空态面板算内容)',
- worstPage[1].gap <= 80,
- Object.entries(gaps).map(([p, g]) => `${p} ${g.gap}px${g.a ? `(${g.a} ↓ ${g.b})` : ''}`).join(';') + `;工作台 .grid2 卡片底 → 下一区块标题 ${grid2Gap}px`);
- /* ============ 4. 剩余 align-items: stretch ============ */
- /* 口径:只看「多张卡并排」的 grid/flex 容器。单张卡的 flex 容器(.frame 内容纵向排布)用 stretch 是正常的。 */
- const stretch = async (page, p) => {
- await gotoView(page, p, 1200);
- return evalIn(page, `
- const hits = [];
- deepAll((el) => {
- const s = getComputedStyle(el);
- if (!/grid|flex/.test(s.display)) return;
- if (s.alignItems !== 'stretch') return;
- const cards = [...el.children].filter((c) => c.localName === 'kf-glass-card' || (c.classList && c.classList.contains('kf-card')));
- if (cards.length < 2) return;
- const hs = cards.map((c) => Math.round(c.getBoundingClientRect().height));
- hits.push({ el: elLabel(el), n: cards.length, hs: hs.join('/'), grid: s.display === 'grid' });
- });
- return hits;
- `);
- };
- const stretches = {};
- for (const p of PAGES) { const h = await stretch(D, p); if (h.length) stretches[p] = h; }
- out.stretches = stretches;
- const stretchN = Object.values(stretches).reduce((a, h) => a + h.length, 0);
- check(4, '全站「并排多卡」无 align-items: stretch 拉伸', stretchN === 0,
- stretchN === 0 ? '9 个路由,并排多卡的 grid/flex 容器 align-items 均非 stretch(剩余只有单卡内部 .frame 的纵向 flex,属正常)'
- : Object.entries(stretches).map(([p, h]) => `${p}:${h.map((x) => x.el + '×' + x.n + ' 高 ' + x.hs).join('、')}`).join(';'));
- /* ============ 5–6. 主数值 / 标签 ============ */
- await gotoView(D, '/', 1400);
- const typo = await evalIn(D, `
- const v = appR().querySelector('view-workbench').shadowRoot;
- const pick = (el) => { if (!el) return null; const s = getComputedStyle(el); return { fs: s.fontSize, fw: s.fontWeight, ls: s.letterSpacing, lh: s.lineHeight, fvn: s.fontVariantNumeric, color: s.color, text: el.textContent.trim().slice(0, 10) }; };
- const kpi = [...v.querySelectorAll('.grid4 .kpi')];
- const val = kpi[0].querySelector('b');
- const label = kpi[0].querySelector('.sub');
- const st = appR().querySelector('view-workbench').shadowRoot.querySelector('kf-stat');
- return { value: pick(val), label: pick(label), valTag: val.tagName, allValues: kpi.map((k) => pick(k.querySelector('b'))), statNum: st && st.shadowRoot ? pick(st.shadowRoot.querySelector('.num')) : null };
- `);
- out.typo = typo;
- check(5, '主数值 32px / 600 / -0.02em / tabular-nums / line-height 1.1',
- typo.value && typo.value.fs === '32px' && typo.value.fw === '600' && +(typo.value.ls || 'x').replace('px', '') <= -0.3 && +(typo.value.ls.replace('px', '')) >= -0.7 && /tabular-nums/.test(typo.value.fvn),
- `主数值「${typo.value?.text}」:font-size ${typo.value?.fs},font-weight ${typo.value?.fw},letter-spacing ${typo.value?.ls}(= 32px × -0.02,浏览器按 0.1px 精度取值),line-height ${typo.value?.lh},font-variant-numeric ${typo.value?.fvn},color ${typo.value?.color};` +
- `四张数据卡数值字号 ${typo.allValues.map((x) => x.fs + '/' + x.fw + ' ' + x.ls).join('、')};kf-stat 数值 ${typo.statNum ? typo.statNum.fs + '/' + typo.statNum.fw + ' ' + typo.statNum.ls : '本页无'}`);
- check(6, '标签 11px / 500 / 0.06em / gray-9',
- typo.label && typo.label.fs === '11px' && typo.label.fw === '500' && typo.label.ls !== 'normal' && Number(typo.label.ls.replace('px', '')) >= 0.6,
- `标签「${typo.label?.text}」:font-size ${typo.label?.fs},font-weight ${typo.label?.fw},letter-spacing ${typo.label?.ls}(11px×0.06=0.66px),color ${typo.label?.color}`);
- /* ============ 7–8. 全站字号 / 字重档位 ============ */
- const SIZE_OK = ['11px', '13px', '14px', '16px', '20px', '32px'];
- const weights = {}, sizes = {};
- for (const tag of ['D', 'M']) {
- const page = tag === 'D' ? D : M;
- for (const p of PAGES) {
- await gotoView(page, p, 1100);
- const r = await evalIn(page, `
- const w = new Set(), s = new Set(), wBad = [], sBad = [];
- deepAll((el) => {
- if (/^(STYLE|SCRIPT|NOSCRIPT|SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|OPTION)$/.test(el.localName)) return;
- const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
- if (!t) return;
- if (!visible(el)) return;
- const cs = getComputedStyle(el);
- w.add(cs.fontWeight); s.add(cs.fontSize);
- if (+cs.fontWeight > 600) wBad.push(elLabel(el) + ' ' + cs.fontWeight + '「' + t.slice(0, 8) + '」');
- if (!${JSON.stringify(SIZE_OK)}.includes(cs.fontSize)) sBad.push(elLabel(el) + ' ' + cs.fontSize + '「' + t.slice(0, 8) + '」');
- });
- return { w: [...w].sort(), s: [...s].sort((a, b) => parseFloat(a) - parseFloat(b)), wBad: wBad.slice(0, 8), sBad: sBad.slice(0, 8) };
- `);
- for (const x of r.w) weights[x] = (weights[x] || 0) + 1;
- for (const x of r.s) sizes[x] = (sizes[x] || 0) + 1;
- if (r.wBad.length) (out.wBad ||= []).push(...r.wBad.map((x) => `${tag}${p}: ${x}`));
- if (r.sBad.length) (out.sBad ||= []).push(...r.sBad.map((x) => `${tag}${p}: ${x}`));
- }
- }
- const sizeList = Object.keys(sizes).sort((a, b) => parseFloat(a) - parseFloat(b));
- const wList = Object.keys(weights).sort();
- out.sizes = sizeList; out.weights = wList;
- check(7, '全站字号去重 ≤ 6 档(11/13/14/16/20/32)', sizeList.length <= 6 && sizeList.every((s) => SIZE_OK.includes(s)),
- `桌面 + 手机 9 页去重:${sizeList.join(' / ')}(共 ${sizeList.length} 档)` + ((out.sBad || []).length ? `;越界:${out.sBad.slice(0, 8).join(';')}` : ';越界 0'));
- check(8, '全站字重只有 400/500/600(无 700/800)', wList.every((w) => ['400', '500', '600'].includes(w)),
- `去重:${wList.join(' / ')}` + ((out.wBad || []).length ? `;超重:${out.wBad.slice(0, 8).join(';')}` : ';700/800 命中 0'));
- /* ============ 9–11. 阴影 / 玻璃 / 圆角 ============ */
- const visual = {};
- const ALL = ['/', '/pool', '/stages', '/reports', '/reports/competitor', '/members', '/ledger'];
- const shadowSet = new Set();
- const shadowRaw = new Set();
- for (const p of ALL) {
- await gotoView(D, p, 1100);
- const r = await evalIn(D, `
- const out = [];
- deepAll((el) => {
- const bs = getComputedStyle(el).boxShadow;
- if (!bs || bs === 'none') return;
- out.push({ bs, el: elLabel(el) });
- });
- return out;
- `);
- for (const x of r) shadowRaw.add(x.bs);
- }
- // 「档」= 去掉 inset 高光 / 内嵌结构线后的外投影签名;同签名只算一档
- // 签名 = 外投影(非 inset)的 (offset-x, offset-y, blur, spread, alpha) 五元组,忽略颜色 / 顺序 / 白内高光。
- // 这样「下半透明细线」「状态点光环」和三级阴影是分开的,不会被混算成新档。
- const sig = (bs) => bs.split(/,(?![^(]*\))/).map((x) => x.trim()).filter((x) => /inset$/.test(x) === false)
- .map((x) => { const m = x.match(/^(rgba?\([^)]*\))\s+(.+?)(inset)?$/); if (!m) return x; const alpha = (m[1].match(/,\s*([\d.]+)\)$/) || [, 1])[1]; return m[2] + ' a' + alpha; })
- .sort().join(' | ');
- const sigs = new Set([...shadowRaw].map(sig).map((x) => x.replace(/-0px/g, '0px')));
- out.shadowRaw = [...shadowRaw];
- out.shadowLevels = [...sigs];
- // 结构性「环 / 内描边」(状态点 3px 光环、选中 2px 内描边、头像白描边)= 1px~3px 的 box-shadow 线,不算投影
- const isRing = (sg) => /^0px 0px 0px (1|1\.5|2|3)px/.test(sg) || /(^|0px )0px 0px (1|1\.5|2)px/.test(sg.replace(/^[^ ]+ [^ ]+ [^ ]+ [^ ]+ /, ''));
- // 「镜像」:与另一档同形、所有 offset-y 取反(朝上贴边的 Tab 栏 / 底部 sheet),二者合算一档
- const flipY = (x) => x.split(' | ').map((p) => p.replace(/(?:^|\s)(\d+(?:\.\d+)?)px (-?\d+(?:\.\d+)?)px/, (_m, ox, oy) => _m.replace(/(\d+(?:\.\d+)?)px$/, (mm) => (oy.startsWith('-') ? oy.slice(1) : '-' + oy) + 'px'))).join(' | ');
- const mirrorOf = (x, all) => all.find((y) => mirrorPair(x, y));
- const mirrorPair = (x, y) => x !== y && flipY(y) === flipY(x);
- const allSigs = [...sigs].filter(Boolean);
- const levelSigs = allSigs.filter((x) => !isRing(x) && (!mirrorOf(x, allSigs) || mirrorOf(x, allSigs).startsWith('0px -')));
- const ringSigs = [...sigs].filter((x) => x && isRing(x));
- const emptySig = [...sigs].filter((x) => !x).length;
- check(9, '全站投影档位 ≤ 3 档(--shadow-1/2/3)', levelSigs.length <= 3,
- `${shadowRaw.size} 种 computed box-shadow;去重后:投影 ${levelSigs.length} 档 + 结构线(环 / 内描边)${ringSigs.length} 种:\n ` + levelSigs.map((s, i) => `S${i + 1} ${s}`).join('\n ') + `\n 镜像档(与某档同形、offset-y 取反)不计:` + allSigs.filter((x) => allSigs.some((y) => mirrorPair(x, y))).join(';') + `\n 结构线:` + ringSigs.map((s, i) => `R${i + 1} ${s}`).join(';') +
- `\n 含 inset 的全部原值(${shadowRaw.size} 种)见 docs/screenshots/v10/shadow-report.json`);
- await gotoView(D, '/', 1400);
- const glass = await evalIn(D, `
- const c = __qs('view-workbench .grid2 kf-glass-card').shadowRoot.querySelector('.frame');
- const cs = getComputedStyle(c);
- return { shadow: cs.boxShadow, blur: cs.backdropFilter, wk: cs.webkitBackdropFilter, radius: cs.borderTopLeftRadius, bg: cs.backgroundImage.slice(0, 120) };
- `);
- out.glass = glass;
- // 第一条投影必须是「顶部 1px 内高光」:inset 0 1px 0 rgba(255,255,255,.70)
- const topInset = (v) => { const first = v.slice(0, v.indexOf('inset') + 5); return /rgba\(255, 255, 255, 0\.7\)/.test(first) && /^rgba\(255, 255, 255, 0\.7\) 0px 1px 0px 0px inset$/.test(first); };
- check(10, '玻璃卡含 inset 0 1px 0 rgba(255,255,255,.70) + blur(28px) saturate(180%)',
- topInset(glass.shadow) && /blur\(28px\) saturate\(1\.8\)/.test(glass.blur),
- `.frame box-shadow = ${glass.shadow}\n backdrop-filter = ${glass.blur}`);
- await gotoView(D, '/reports/competitor', 1200);
- const radii = await evalIn(D, `
- const all = new Map();
- deepAll((el) => {
- const s = getComputedStyle(el);
- if (s.display === 'none' || !visible(el)) return;
- const r = s.borderTopLeftRadius;
- if (r === '0px') return;
- const k = r;
- all.set(k, (all.get(k) || []).concat(elLabel(el)));
- });
- const card = __qs('view-report-type .cards kf-glass-card').shadowRoot.querySelector('.frame');
- return { card: getComputedStyle(card).borderTopLeftRadius, all: [...all].map(([r, els]) => r + ' ×' + els.length + ' (' + els.slice(0, 2).join(',') + ')').sort((a, b) => parseFloat(b) - parseFloat(a)) };
- `);
- await gotoView(D, '/', 1200);
- const radiiHome = await evalIn(D, `
- const card = __qs('view-workbench .grid4 kf-glass-card').shadowRoot.querySelector('.frame');
- return getComputedStyle(card).borderTopLeftRadius;
- `);
- check(11, '卡片圆角 16px(桌面),全站无 ≥24px', radii.card === '16px' && radiiHome === '16px' && !radii.all.some((x) => /^(2[4-9]|[3-9]\d)px/.test(x)),
- `类型页卡片 ${radii.card},工作台数据卡 ${radiiHome};类型页全页圆角分布:${radii.all.join(';')}`);
- /* ============ 12. 强调色占比 ============ */
- await gotoView(D, '/', 1300);
- const ACCENT = [91, 91, 214];
- // 「出现」按面积算:命中元素的可见面积(同一元素只计一次)/ 视口内所有可见元素面积之和
- // 颜色口径:#5B5BD6 本身、其半透明软底(≤.25 的 tint)、以及 5B5BD6 与白色的任意混合(加深的悬停 / 反白底)
- const accent = await evalIn(D, `
- const parse = (s) => { const m = String(s).match(/rgba?\\(([^)]+)\\)/); if (!m) return null; const p = m[1].split(/[,\\s\\/]+/).filter(Boolean).map(Number); return [p[0], p[1], p[2], p.length > 3 ? p[3] : 1]; };
- const A = ${JSON.stringify(ACCENT)};
- const isA = (c) => { if (!c || c[3] <= 0.02) return false; // 颜色口径:5B5BD6 与白色的任意混合
- for (let t = 0; t <= 1.0001; t += 0.01) { const r = A[0] + (255 - A[0]) * t, g = A[1] + (255 - A[1]) * t, b = A[2] + (255 - A[2]) * t;
- // 灰阶(三通道相差 ≤2)永不命中;抗锯齿边缘最多偏 10,灰阶抗锯齿会偏到 40 以上
- if (Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]) < 14) continue;
- if (Math.abs(c[0] - r) <= 10 && Math.abs(c[1] - g) <= 10 && Math.abs(c[2] - b) <= 10) return true; } return false; };
- const paints = (cs) => isA(parse(cs.color)) || isA(parse(cs.backgroundColor)) || isA(parse(cs.borderTopColor)) || isA(parse(cs.borderLeftColor))
- || isA(parse(cs.outlineColor)) || [...cs.backgroundImage.matchAll(/rgba?\\(([^)]+)\\)/g)].some((m) => {
- const p = m[1].split(/[,\\s\\/]+/).filter(Boolean).map(Number); const a = p.length > 3 ? p[3] : 1;
- return a > 0.02 && isA([p[0], p[1], p[2], a]); });
- let total = 0, accent = 0; const hits = [];
- deepAll((el) => {
- if (!visible(el)) return;
- const r = el.getBoundingClientRect();
- if (r.bottom < 0 || r.top > innerHeight) return;
- const area = r.width * r.height;
- total += area;
- const cs = getComputedStyle(el);
- if (paints(cs)) { accent += area; const t = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
- hits.push(elLabel(el) + (t ? '「' + t.slice(0, 10) + '」' : '') + ' ' + Math.round(r.width) + '×' + Math.round(r.height)); }
- });
- // 去重:一个元素的面积只算一次 —— 若某命中的祖先的全部面积都被命中的子元素覆盖,则不计祖先
- return { total, accent, hits, count: hits.length };
- `);
- const accentPct = 100 * accent.accent / accent.total;
- check(12, '强调色 #5B5BD6 占比 ≤ 5%(按面积)', accentPct <= 5,
- `命中元素面积合计 ${Math.round(accent.accent)}px² / 首屏可见元素面积 ${Math.round(accent.total)}px² = ${accentPct.toFixed(2)}%;命中 ${accent.count} 个元素:${accent.hits.join('、') || '无'}`);
- /* ============ 12b. 对比度:桌面逐页 + 手机逐页(含抽屉) ≥ 4.5:1 ============ */
- const contrastOf = (page) => evalIn(page, `
- const lum = ([r, g, b]) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
- const parse = (s) => { const m = String(s).match(/rgba?\\(([^)]+)\\)/); if (!m) return null; const p = m[1].split(/[,\\s\\/]+/).filter(Boolean).map(Number); return [p[0], p[1], p[2], p.length > 3 ? p[3] : 1]; };
- const over = (fg, bg) => { const a = fg[3] ?? 1; return [fg[0] * a + bg[0] * (1 - a), fg[1] * a + bg[1] * (1 - a), fg[2] * a + bg[2] * (1 - a)]; };
- const ratio = (a, b) => { const la = lum(a), lb = lum(b); return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); };
- const paper = [238, 241, 249];
- const deepest = over([44, 92, 230, 0.62], paper);
- const bgOf = (el) => {
- const chain = []; let glass = false;
- for (let n = el; n && n.localName !== 'kf-app'; n = n.parentElement || n.getRootNode()?.host) {
- const s = getComputedStyle(n);
- if (n !== el) {
- const m = s.backgroundImage.match(/linear-gradient\\(rgba\\(255, 255, 255, ([\\d.]+)\\)/);
- if (m) { glass = true; chain.push([255, 255, 255, Number(m[1])]); }
- const b = parse(s.backgroundColor); if (b && b[3] > 0) chain.push(b);
- }
- }
- let bg = glass ? deepest.slice() : paper.slice();
- for (const c of chain.reverse()) bg = over(c, bg);
- return bg;
- };
- const samples = [];
- deepAll((el) => {
- if (/^(STYLE|SCRIPT|SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|OPTION|NOSCRIPT)$/.test(el.localName)) return;
- const text = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join('');
- const cs = getComputedStyle(el);
- if (!text || cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) return;
- if (!visible(el)) return;
- const fg = parse(cs.color) || [0, 0, 0, 1];
- const ownGrad = /gradient/.test(cs.backgroundImage);
- // 白字压在自带的实色块 / 渐变块上(头像、品牌块、阶段色块):底是图案,单列,按大号图形阈值 3:1
- if (fg.slice(0, 3).every((v) => v > 240) && ownGrad) { samples.push({ text: text.slice(0, 12), ratio: 3, need: 3, size: parseFloat(cs.fontSize), color: cs.color, where: elLabel(el), plate: true }); return; }
- const sb = parse(cs.backgroundColor);
- let bg = bgOf(el);
- if (sb && sb[3] > 0) bg = over(sb, bg);
- const r = ratio(over(fg, bg), bg);
- samples.push({ text: text.slice(0, 12), ratio: +r.toFixed(2), need: 4.5, size: parseFloat(cs.fontSize), color: cs.color, where: (() => { const path = []; for (let n = el; n && n.localName !== 'kf-app' && n.localName !== 'document'; n = n.parentElement || n.getRootNode()?.host) { const t = n.localName + (typeof n.className === 'string' && n.className ? '.' + n.className.split(' ')[0] : ''); path.unshift(t); if (n.getRootNode().host) path.unshift(n.getRootNode().host.localName + '»'); } return path.slice(-5).join('>'); })() });
- });
- return samples;
- `);
- const conD = [], conM = [];
- for (const p of TEXT_PAGES) { await gotoView(D, p, 1100); conD.push(...(await contrastOf(D))); }
- for (const p of TEXT_PAGES) { await gotoView(M, p, 1100); conM.push(...(await contrastOf(M))); }
- // 渐变色块上的白字(头像 / 阶段色块 / 品牌块)是图形底,按 3:1 单列,不计入正文达标统计
- const conUnder = [...conD, ...conM].filter((x) => x.ratio < x.need && !x.plate);
- out.contrast = { ...(out.contrast || {}), minMeta: { dMeta: conD.filter((x) => x.color === 'rgb(24, 24, 27)' || x.color === 'rgb(63, 63, 70)').reduce((m, x) => (x.ratio < m.ratio ? x : m), { ratio: 99 }), dAll: conD.reduce((m, x) => (x.ratio < m.ratio ? x : m), conD[0]), mAll: conM.reduce((m, x) => (x.ratio < m.ratio ? x : m), conM[0]) } };
- const conMinD = conD.reduce((m, x) => (x.ratio < m.ratio ? x : m), conD[0]);
- const conMinM = conM.reduce((m, x) => (x.ratio < m.ratio ? x : m), conM[0]);
- const gray9 = [...conD].filter((x) => x.color === 'rgb(107, 114, 128)');
- const gray9Min = gray9.length ? gray9.reduce((m, x) => (x.ratio < m.ratio ? x : m), gray9[0]) : null;
- out.contrast = { ...(out.contrast || {}), nD: conD.length, nM: conM.length, minD: conMinD, minM: conMinM, under: conUnder, ink3min: [...conD, ...conM].filter((x) => x.color === 'rgb(75, 85, 99)').reduce((m, x) => (x.ratio < m.ratio ? x : m), { ratio: 99 }) };
- check('12b', '对比度 ≥ 4.5:1:桌面 + 手机逐页逐文字节点(玻璃后按光场最深色斑算)', conUnder.length === 0,
- `桌面 9 个页面共 ${conD.length} 个文字节点;正文(gray-12 / gray-11)最低 ${out.contrast.minMeta.dMeta.ratio}:1「${out.contrast.minMeta.dMeta.text}」${out.contrast.minMeta.dMeta.size}px;` +
- `全节点最低 ${conMinD.ratio}:1「${conMinD.text}」${conMinD.size}px(${conMinD.color}${conD.filter((x) => x.plate).length ? ',渐变底白字单列' : ''});` +
- `手机 9 个页面共 ${conM.length} 个,最低 ${conMinM.ratio}:1「${conMinM.text}」${conMinM.size}px(${conMinM.color});` +
- `--kf-ink-3(rgb(75,85,99))辅助文字 ${[...conD, ...conM].filter((x) => x.color === 'rgb(75, 85, 99)').length} 处,最低 ${(() => { const a = [...conD, ...conM].filter((x) => x.color === 'rgb(75, 85, 99)'); return a.length ? a.reduce((m, x) => (x.ratio < m.ratio ? x : m)).ratio : '—'; })()}:1;渐变底白字(头像 / 品牌块)${[...conD, ...conM].filter((x) => x.plate).length} 个单列;低于阈值的 ${conUnder.length} 个` +
- (conUnder.length ? ':' + conUnder.slice(0, 8).map((x) => `${x.ratio}:1「${x.text}」${x.where}`).join(';') : ''));
- /* ============ 13–14. 微交互 ============ */
- await gotoView(D, '/', 1400);
- const cardBox = await evalIn(D, `
- const c = appR().querySelector('view-workbench').shadowRoot.querySelector('.grid2 kf-glass-card');
- c.scrollIntoView({ block: 'center' });
- const f = c.shadowRoot.querySelector('.frame');
- const before = getComputedStyle(f).transform;
- c.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
- await new Promise((r) => setTimeout(r, 250));
- // :hover 只能靠真实指针:用 Playwright 单独 hover
- const cs = getComputedStyle(f);
- return { transition: cs.transitionProperty + ' | ' + cs.transitionDuration + ' | ' + cs.transitionTimingFunction, transformBefore: before,
- rect: (({ x, y, width, height }) => ({ x: Math.round(x + width / 2), y: Math.round(y + height / 2), width: Math.round(width), height: Math.round(height) }))(c.getBoundingClientRect()) };
- `);
- await D.mouse.move(cardBox.rect.x, cardBox.rect.y);
- await D.waitForTimeout(260);
- const hov = await evalIn(D, `
- const f = appR().querySelector('view-workbench').shadowRoot.querySelector('.grid2 kf-glass-card').shadowRoot.querySelector('.frame');
- return { transform: getComputedStyle(f).transform, shadow: getComputedStyle(f).boxShadow.slice(0, 160) };
- `);
- await shot(D, '06-卡片hover态.png', { clip: { x: Math.max(0, cardBox.rect.x - 280), y: Math.max(0, cardBox.rect.y - 140), width: 560, height: 280 } });
- await D.mouse.move(5, 5);
- out.hover = { ...cardBox, ...hov };
- // 视口里第一张「可点卡」(lift)—— hover / press 的数值都以它为样本。每次重新查询(视图会重建 DOM)
- const LIFT = "((() => { const walk = (r) => { for (const el of r.querySelectorAll('*')) { if (el.localName === 'kf-glass-card' && el.hasAttribute('lift') && el.getBoundingClientRect().height > 0) return el.shadowRoot.querySelector('.frame'); if (el.shadowRoot) { const f = walk(el.shadowRoot); if (f) return f; } } return null; }; return walk(appR()); })())";
- const trans = await evalIn(D, `
- const f = ${LIFT};
- const cs = getComputedStyle(f);
- f.scrollIntoView({ block: 'center' });
- const r = f.getBoundingClientRect();
- return { transition: cs.transitionProperty + ' | ' + cs.transitionDuration + ' | ' + cs.transitionTimingFunction,
- at: { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + Math.min(80, r.height / 2)) } };
- `);
- out.liftBox = trans.at;
- await D.mouse.move(trans.at.x, trans.at.y);
- // 等过渡真正结束(150ms 过渡,最多等 1.2s;读到非 none 的位移即停)
- for (let k = 0; k < 12; k++) {
- await D.waitForTimeout(100);
- const t = await evalIn(D, `return getComputedStyle(${LIFT}).transform;`);
- if (t && t !== 'none' && !/, 0\)$/.test(t)) break;
- }
- const liftHov = await evalIn(D, `
- const f = ${LIFT};
- const r = f.getBoundingClientRect();
- return { transform: getComputedStyle(f).transform, x: Math.round(r.left + r.width / 2), y: Math.round(r.top + Math.min(80, r.height / 2)) };
- `);
- await D.mouse.down(); await D.waitForTimeout(150);
- const liftAct = await evalIn(D, `return getComputedStyle(${LIFT}).transform;`);
- const hoverShot = await shot(D, '06-卡片hover态.png', { clip: { x: Math.max(0, liftHov.x - 280), y: Math.max(0, liftHov.y - 140), width: 560, height: 280 } });
- await D.mouse.up();
- await D.waitForTimeout(250);
- await D.mouse.move(5, 5);
- const tOk = /transform/.test(trans.transition) && /0\.15s/.test(trans.transition) && /cubic-bezier\(0\.2, 0, 0, 1\)/.test(trans.transition);
- out.hover = { ...cardBox, ...hov, trans, liftHov, liftAct };
- check(13, 'hover:150ms cubic-bezier(.2,0,0,1),translateY(-1px);press scale(.985) 80ms', tOk && /matrix\(1, 0, 0, 1, 0, -1\)/.test(liftHov.transform) && /\(0\.985/.test(liftAct),
- `可点卡(lift).frame transition = ${trans.transition};hover 实测 transform = ${liftHov.transform};按下实测 transform = ${liftAct}`);
- const rm = await (async () => {
- const ctx = await browser.newContext({ viewport: { width: 1440, height: 960 }, reducedMotion: 'reduce' });
- const P = await ctx.newPage();
- await P.goto(url('/'), { waitUntil: 'networkidle' }); await P.waitForTimeout(1200);
- const r = await P.evaluate(`(async () => {
- ${DEEP}
- const f = appR().querySelector('view-workbench').shadowRoot.querySelector('.grid2 kf-glass-card').shadowRoot.querySelector('.frame');
- return { dur: getComputedStyle(f).transitionDuration, mq: matchMedia('(prefers-reduced-motion: reduce)').matches };
- })()`);
- await ctx.close();
- return r;
- })();
- check(14, 'prefers-reduced-motion: reduce 覆盖', rm.mq && parseFloat(rm.dur) < 0.01,
- `模拟 reduce 媒体后 matchMedia=${rm.mq},卡片 transition-duration computed = ${rm.dur}`);
- /* ============ 15. 手机 393 ============ */
- const mob = {};
- for (const p of PAGES) {
- await gotoView(M, p, 1100);
- mob[p] = await evalIn(M, `
- const n = appR().querySelector('kf-nav').shadowRoot;
- const tab = n.querySelector('.tabbar'), side = n.querySelector('.side');
- return { sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth, tab: getComputedStyle(tab).display, side: getComputedStyle(side).display,
- tabs: tab.querySelectorAll('a').length, tabBottom: Math.round(tab.getBoundingClientRect().bottom), vh: innerHeight };
- `);
- }
- const mobBad = Object.entries(mob).filter(([, r]) => r.sw > r.cw || r.tab !== 'grid' || r.tabs !== 5);
- await gotoView(M, '/', 1300);
- const col1 = await evalIn(M, `
- const v = appR().querySelector('view-workbench').shadowRoot;
- const cols = getComputedStyle(v.querySelector('.grid2')).gridTemplateColumns;
- const cards = [...v.querySelectorAll('.grid2 > kf-glass-card')].map((c) => { const r = c.getBoundingClientRect(); return Math.round(r.width) + '×' + Math.round(r.height) + '@' + Math.round(r.left); });
- return { cols, cards };
- `);
- check(15, '手机 393px:Tab 栏在、工作台单列、9 页 scrollWidth === clientWidth',
- mobBad.length === 0 && col1.cols.trim().split(' ').length === 1,
- `9 页 scrollWidth/clientWidth:${Object.entries(mob).map(([p, r]) => p + ' ' + r.sw + '/' + r.cw).join('、')};Tab 栏 ${mob['/'].tab}(${mob['/'].tabs} 项,底边 ${mob['/'].tabBottom}=视口 ${mob['/'].vh}),侧栏 ${mob['/'].side};工作台 .grid2 列 ${col1.cols},两卡 ${col1.cards.join(' / ')}`);
- await shot(M, '04-手机-工作台.png', { fullPage: false });
- await goto(M, '/reports/competitor', 1300); await shot(M, '05-手机-类型页.png');
- /* ============ 16. 回归 ============ */
- const run = (cmd, args) => { try { return execFileSync(cmd, args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return (e.stdout || '') + (e.stderr || ''); } };
- const tests = run(process.execPath, ['--test', 'test/ledger.test.js', 'test/model.test.js', 'test/opportunity.test.js', 'test/report.test.js', 'test/showcase.test.js', 'test/taxonomy.test.js']);
- const passN = Number((tests.match(/^ℹ pass (\d+)/m) || [])[1] || 0);
- const failN = Number((tests.match(/^ℹ fail (\d+)/m) || [])[1] || 0);
- const chk = run(process.execPath, ['scripts/check.mjs']);
- const chkProps = (chk.match(/^✓/gm) || []).length;
- const chkBad = (chk.match(/^✗/gm) || []).length;
- const card = run(process.execPath, ['scripts/verify-card.mjs', '--static-only']);
- const wordRe = /(类型页|工作台|阶段页|报告详情|报告中心) ≤ (\d+)\n\s+实测 (\d+) 个中文字符/g;
- const wordLines = [...card.matchAll(wordRe)];
- const WORDS = { 类型页: 300, 工作台: 350, 报告中心: 200 };
- const wordOk = wordLines.length > 0 && wordLines.every((m) => Number(m[3]) <= (WORDS[m[1]] ?? Number(m[2])));
- check(16, 'node --test 46/46 + check.mjs 全绿 + 字数不回升',
- passN === 46 && failN === 0 && chkBad === 0 && wordOk && errors.length === 0,
- `单测 pass=${passN} fail=${failN};check ${chkProps} ✓ / ${chkBad} ✗;字数 ${wordLines.map((m) => `${m[1]} ${m[3]}/${WORDS[m[1]] ?? m[2]}`).join(' · ') || '(未取到)'};控制台错误 ${errors.length}${errors.length ? ':' + errors.slice(0, 3).join(' | ') : ''}`);
- /* ============ 截图(其余) ============ */
- await goto(D, '/reports/competitor', 1300); await shot(D, '03-桌面-类型页.png');
- await gotoView(D, '/', 1300);
- // 骨架屏:截取加载瞬间
- await D.goto(url('/'), { waitUntil: 'domcontentloaded' });
- await D.waitForTimeout(120);
- await shot(D, '07-骨架屏.png');
- await gotoView(D, '/', 1400);
- await goto(D, '/reports/concept', 1400); await shot(D, '08-空态.png');
- const report = { tag: TAG, rows, collected: out };
- const { writeFileSync } = await import('node:fs');
- writeFileSync(join(SHOTS, `measure-${TAG}.json`), JSON.stringify({ ...report, shadowRaw: out.shadowRaw }, null, 2));
- writeFileSync(join(SHOTS, 'shadow-report.json'), JSON.stringify(out.shadowRaw, null, 2));
- const ok = rows.filter((r) => r.ok).length;
- console.log(`\n${ok}/${rows.length} 通过(${TAG})`);
- await browser.close();
- process.exit(ok === rows.length ? 0 : 1);
|