378 lines
17 KiB
JavaScript
378 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* ui-rendered-audit.mjs — 渲染态视觉秩序审计(与静态审计互补)
|
||
*
|
||
* 静态脚本证明"源码写了什么",本脚本证明"用户实际看到什么":
|
||
* 在 390×844 移动视口的真实 Chromium 中逐页渲染 H5 构建产物,
|
||
* 用 getComputedStyle 收集每个可见元素的生效颜色/背景/字号/圆角,
|
||
* 与 base.css token palette(共享 lib/ui-tokens.mjs)比对;
|
||
* 同时检查横向溢出、控制台错误,并留存截图证据。
|
||
*
|
||
* 用法:
|
||
* node scripts/ui-rendered-audit.mjs # 静态服务 dist/build/h5(需先 build:h5)
|
||
* node scripts/ui-rendered-audit.mjs --url http://127.0.0.1:5175/h5/ # 审计 dev server
|
||
* node scripts/ui-rendered-audit.mjs --routes pages/auth/login/index,pages/user/today/index
|
||
*
|
||
* 输出:output/ui-ux/rendered-audit-latest.{md,json} + output/ui-ux/rendered-shots/*.png
|
||
* 注意:默认静态服务不带后端代理,页面以未登录/错误/空态渲染——这正是三态覆盖的一部分;
|
||
* 认证态渲染需 --url 指向带后端的入口并另行登录(见报告说明)。
|
||
*/
|
||
import http from 'node:http';
|
||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { spawn } from 'node:child_process';
|
||
import {
|
||
mobileRoot,
|
||
normalizeComputedColor,
|
||
NEAR_MISS_MAX_DIST,
|
||
loadTokens,
|
||
} from './lib/ui-tokens.mjs';
|
||
|
||
const { palette, fontScale, radiusTokens, nearestPalette } = loadTokens();
|
||
|
||
// 已知的框架默认样式来源,照常统计但单独标注,不计入近色漂移
|
||
const KNOWN_FRAMEWORK_COLORS = {
|
||
'#000000': 'uni-app 内置组件默认色(uni-button/uni-picker 等,业务未定制时生效)',
|
||
};
|
||
|
||
// ---------- 参数 ----------
|
||
const args = process.argv.slice(2);
|
||
const argValue = (name) => {
|
||
const i = args.indexOf(name);
|
||
return i >= 0 ? args[i + 1] : undefined;
|
||
};
|
||
const VIEW_W = Number(argValue('--width') || 390);
|
||
const VIEW_H = Number(argValue('--height') || 844);
|
||
const outDir = path.resolve(argValue('--out') || path.join(mobileRoot, '..', 'output', 'ui-ux'));
|
||
const DEFAULT_ROUTES = [
|
||
'pages/auth/login/index',
|
||
'pages/user/today/index',
|
||
'pages/user/sop/index',
|
||
'pages/user/practice/index',
|
||
'pages/candidate/index/index',
|
||
'pages/supervisor/index/index',
|
||
];
|
||
const routes = (argValue('--routes') || DEFAULT_ROUTES.join(',')).split(',').map((s) => s.trim()).filter(Boolean);
|
||
|
||
// ---------- H5 静态服务(SPA 回退) ----------
|
||
const MIME = {
|
||
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
|
||
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp',
|
||
'.svg': 'image/svg+xml', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
|
||
'.mp3': 'audio/mpeg', '.ico': 'image/x-icon',
|
||
};
|
||
function serveH5() {
|
||
const h5Dir = path.join(mobileRoot, 'dist', 'build', 'h5');
|
||
if (!existsSync(path.join(h5Dir, 'index.html'))) {
|
||
throw new Error(`未找到 H5 构建产物 ${h5Dir},请先 npm run build:h5,或用 --url 指向已有服务`);
|
||
}
|
||
const server = http.createServer((req, res) => {
|
||
let p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
||
if (p.startsWith('/h5/')) p = p.slice(3);
|
||
if (p === '/' || p === '') p = '/index.html';
|
||
let file = path.join(h5Dir, p);
|
||
if (!file.startsWith(h5Dir) || !existsSync(file) || !file.includes('.')) {
|
||
file = path.join(h5Dir, 'index.html'); // SPA fallback
|
||
}
|
||
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
|
||
res.end(readFileSync(file));
|
||
});
|
||
return new Promise((resolve) => {
|
||
server.listen(0, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}/h5/` }));
|
||
});
|
||
}
|
||
|
||
// ---------- Chrome 定位与启动 ----------
|
||
function findChrome() {
|
||
if (process.env.CHROME_BIN) return process.env.CHROME_BIN;
|
||
const candidates = [
|
||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||
'/usr/bin/google-chrome',
|
||
];
|
||
for (const c of candidates) if (existsSync(c)) return c;
|
||
const pwDir = path.join(process.env.LOCALAPPDATA || '', 'ms-playwright');
|
||
if (existsSync(pwDir)) {
|
||
for (const entry of readdirSync(pwDir).filter((d) => d.startsWith('chromium_headless_shell')).sort().reverse()) {
|
||
const bin = path.join(pwDir, entry, 'chrome-win', 'headless_shell.exe');
|
||
if (existsSync(bin)) return bin;
|
||
}
|
||
for (const entry of readdirSync(pwDir).filter((d) => d.startsWith('chromium-')).sort().reverse()) {
|
||
const bin = path.join(pwDir, entry, 'chrome-win', 'chrome.exe');
|
||
if (existsSync(bin)) return bin;
|
||
}
|
||
}
|
||
throw new Error('未找到 Chrome/Chromium,请设置 CHROME_BIN');
|
||
}
|
||
|
||
async function launchChrome() {
|
||
const chromeBin = findChrome();
|
||
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'ui-rendered-audit-'));
|
||
const proc = spawn(chromeBin, [
|
||
'--headless=new', '--disable-gpu', '--no-first-run', '--ignore-certificate-errors',
|
||
'--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, 'about:blank',
|
||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||
const port = await new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => reject(new Error('Chrome DevTools 启动超时')), 10000);
|
||
proc.stderr.on('data', (chunk) => {
|
||
const m = String(chunk).match(/DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//);
|
||
if (m) { clearTimeout(timer); resolve(Number(m[1])); }
|
||
});
|
||
proc.on('exit', (code) => reject(new Error(`Chrome 退出: ${code}`)));
|
||
});
|
||
return { proc, port, userDataDir, chromeBin };
|
||
}
|
||
|
||
async function connectPage(port) {
|
||
const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
|
||
const wsUrl = targets.find((t) => t.type === 'page')?.webSocketDebuggerUrl;
|
||
const ws = new WebSocket(wsUrl);
|
||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
||
let id = 0;
|
||
const pending = new Map();
|
||
const listeners = [];
|
||
ws.onmessage = (event) => {
|
||
const msg = JSON.parse(event.data);
|
||
if (msg.id && pending.has(msg.id)) {
|
||
const { resolve, reject } = pending.get(msg.id);
|
||
pending.delete(msg.id);
|
||
msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result || {});
|
||
return;
|
||
}
|
||
listeners.forEach((fn) => fn(msg));
|
||
};
|
||
return {
|
||
send(method, params = {}) {
|
||
const callId = ++id;
|
||
ws.send(JSON.stringify({ id: callId, method, params }));
|
||
return new Promise((resolve, reject) => pending.set(callId, { resolve, reject }));
|
||
},
|
||
onEvent(fn) {
|
||
listeners.push(fn);
|
||
return () => {
|
||
const index = listeners.indexOf(fn);
|
||
if (index >= 0) listeners.splice(index, 1);
|
||
};
|
||
},
|
||
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
||
close() { try { ws.close(); } catch {} },
|
||
};
|
||
}
|
||
|
||
// ---------- 页面内采集器 ----------
|
||
const COLLECTOR = `(() => {
|
||
const acc = { colors: {}, backgrounds: {}, fontSizes: {}, fontWeights: {}, radii: {}, elements: 0 };
|
||
const bump = (o, k) => { o[k] = (o[k] || 0) + 1; };
|
||
const walk = (root) => {
|
||
for (const el of root.querySelectorAll('*')) {
|
||
const cs = getComputedStyle(el);
|
||
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
|
||
const r = el.getBoundingClientRect();
|
||
if (r.width === 0 && r.height === 0) continue;
|
||
acc.elements++;
|
||
bump(acc.colors, cs.color);
|
||
bump(acc.backgrounds, cs.backgroundColor);
|
||
bump(acc.fontSizes, cs.fontSize);
|
||
bump(acc.fontWeights, cs.fontWeight);
|
||
for (const p of ['borderTopLeftRadius','borderTopRightRadius','borderBottomRightRadius','borderBottomLeftRadius']) {
|
||
bump(acc.radii, cs[p]);
|
||
}
|
||
if (el.shadowRoot) walk(el.shadowRoot);
|
||
}
|
||
};
|
||
walk(document);
|
||
return {
|
||
...acc,
|
||
title: document.title,
|
||
scrollWidth: document.documentElement.scrollWidth,
|
||
innerWidth: window.innerWidth,
|
||
bodyTextLength: document.body ? document.body.innerText.trim().length : 0,
|
||
};
|
||
})()`;
|
||
|
||
// ---------- 主流程 ----------
|
||
const served = argValue('--url') ? null : await serveH5();
|
||
const baseUrl = argValue('--url') || served.url;
|
||
console.log(`[setup] baseUrl=${baseUrl}`);
|
||
const shotsDir = path.join(outDir, 'rendered-shots');
|
||
mkdirSync(shotsDir, { recursive: true });
|
||
|
||
const chrome = await launchChrome();
|
||
console.log(`[setup] chrome pid=${chrome.proc.pid} port=${chrome.port} bin=${chrome.chromeBin}`);
|
||
const page = await connectPage(chrome.port);
|
||
console.log('[setup] page connected');
|
||
const pages = [];
|
||
|
||
try {
|
||
await page.send('Page.enable');
|
||
await page.send('Runtime.enable');
|
||
await page.send('Emulation.setDeviceMetricsOverride', {
|
||
width: VIEW_W, height: VIEW_H, deviceScaleFactor: 2, mobile: true,
|
||
});
|
||
await page.send('Emulation.setTouchEmulationEnabled', { enabled: true });
|
||
|
||
for (const route of routes) {
|
||
const consoleErrors = [];
|
||
const off = (msg) => {
|
||
if (msg.method === 'Runtime.exceptionThrown') {
|
||
consoleErrors.push(msg.params.exceptionDetails?.exception?.description || msg.params.exceptionDetails?.text || 'exception');
|
||
}
|
||
if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') {
|
||
consoleErrors.push(msg.params.args?.map((a) => a.value ?? a.description ?? '').join(' ').slice(0, 200));
|
||
}
|
||
};
|
||
const stopListening = page.onEvent(off);
|
||
|
||
// 该 H5 构建为 hash 路由(见 index.html 兼容重定向脚本),路由必须走 #/ 前缀
|
||
const url = `${baseUrl.replace(/\/$/, '')}/#/${route}`;
|
||
console.log(`[nav] ${url}`);
|
||
await page.send('Page.navigate', { url });
|
||
// 等 uni-page-body 出现再静置 1.5s 让异步渲染完成
|
||
const started = Date.now();
|
||
for (;;) {
|
||
const r = await page.send('Runtime.evaluate', {
|
||
expression: `!!document.querySelector('uni-page-body')`, returnByValue: true,
|
||
});
|
||
if (r.result?.value || Date.now() - started > 10000) break;
|
||
await page.sleep(300);
|
||
}
|
||
await page.sleep(1500);
|
||
|
||
const r = await page.send('Runtime.evaluate', { expression: COLLECTOR, returnByValue: true });
|
||
const stats = r.result?.value;
|
||
const shot = await page.send('Page.captureScreenshot', { format: 'png' });
|
||
const slug = route.replaceAll('/', '-');
|
||
writeFileSync(path.join(shotsDir, `${slug}.png`), Buffer.from(shot.data, 'base64'));
|
||
stopListening();
|
||
|
||
pages.push({ route, url, stats, consoleErrors: consoleErrors.filter(Boolean).slice(0, 10) });
|
||
console.log(`[rendered] ${route} elements=${stats?.elements ?? '?'} errors=${consoleErrors.filter(Boolean).length}`);
|
||
}
|
||
} finally {
|
||
page.close();
|
||
chrome.proc.kill('SIGTERM');
|
||
try { rmSync(chrome.userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); } catch {}
|
||
if (served) await new Promise((r) => served.server.close(r));
|
||
}
|
||
|
||
// ---------- 分析 ----------
|
||
function classifyColors(rawMap, { skipTransparent }) {
|
||
const agg = new Map();
|
||
for (const [raw, count] of Object.entries(rawMap)) {
|
||
const v = normalizeComputedColor(raw);
|
||
if (skipTransparent && v === 'transparent') continue;
|
||
agg.set(v, (agg.get(v) || 0) + count);
|
||
}
|
||
const onPalette = new Map();
|
||
const offPalette = new Map();
|
||
for (const [v, count] of agg) {
|
||
if (palette.has(v)) onPalette.set(v, count);
|
||
else offPalette.set(v, count);
|
||
}
|
||
const offDetailed = [...offPalette.entries()]
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([v, count]) => {
|
||
const framework = KNOWN_FRAMEWORK_COLORS[v] || null;
|
||
const nearest = !framework && v.startsWith('#') && v.length === 7 ? nearestPalette(v) : null;
|
||
return { value: v, count, nearest, framework, nearMiss: !!nearest && nearest.dist > 0 && nearest.dist <= NEAR_MISS_MAX_DIST };
|
||
});
|
||
return {
|
||
onPaletteCount: [...onPalette.values()].reduce((a, b) => a + b, 0),
|
||
offPaletteCount: offDetailed.reduce((a, b) => a + b.count, 0),
|
||
nearMissCount: offDetailed.filter((d) => d.nearMiss).reduce((a, b) => a + b.count, 0),
|
||
offPalette: offDetailed.slice(0, 20),
|
||
};
|
||
}
|
||
|
||
function classifyFontSizes(rawMap) {
|
||
const dist = {};
|
||
const off = {};
|
||
for (const [raw, count] of Object.entries(rawMap)) {
|
||
const px = Number(raw.replace('px', ''));
|
||
if (!px) continue; // font-size:0 是消间隙/图标字体等合法手段,不计入字阶
|
||
dist[px] = (dist[px] || 0) + count;
|
||
if (!fontScale.has(px)) off[px] = (off[px] || 0) + count;
|
||
}
|
||
return { distribution: dist, offScale: off };
|
||
}
|
||
|
||
function classifyRadii(rawMap) {
|
||
const dist = {};
|
||
const off = {};
|
||
for (const [raw, count] of Object.entries(rawMap)) {
|
||
if (raw.includes('%')) { dist[raw] = (dist[raw] || 0) + count; continue; }
|
||
const px = Number(raw.replace('px', ''));
|
||
if (Number.isNaN(px) || px === 0 || px >= 999) continue;
|
||
dist[px] = (dist[px] || 0) + count;
|
||
if (!radiusTokens.has(px)) off[px] = (off[px] || 0) + count;
|
||
}
|
||
return { distribution: dist, offToken: off };
|
||
}
|
||
|
||
const analyzed = pages.map((p) => ({
|
||
route: p.route,
|
||
url: p.url,
|
||
elements: p.stats?.elements ?? 0,
|
||
bodyTextLength: p.stats?.bodyTextLength ?? 0,
|
||
horizontalOverflow: p.stats ? p.stats.scrollWidth > p.stats.innerWidth : null,
|
||
scrollWidth: p.stats?.scrollWidth,
|
||
innerWidth: p.stats?.innerWidth,
|
||
consoleErrors: p.consoleErrors,
|
||
colors: classifyColors(p.stats?.colors || {}, { skipTransparent: true }),
|
||
backgrounds: classifyColors(p.stats?.backgrounds || {}, { skipTransparent: true }),
|
||
fontSizes: classifyFontSizes(p.stats?.fontSizes || {}),
|
||
radii: classifyRadii(p.stats?.radii || {}),
|
||
}));
|
||
|
||
const fmtDist = (obj, n = 12) =>
|
||
Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, n).map(([v, c]) => `${v}×${c}`).join(',');
|
||
|
||
const lines = [];
|
||
lines.push('# mobile-uni 渲染态 UI 审计');
|
||
lines.push('');
|
||
lines.push(`- 生成时间:${new Date().toISOString()}`);
|
||
lines.push(`- 入口:${baseUrl}(${argValue('--url') ? '外部服务' : '本地静态服务 dist/build/h5'}),视口 ${VIEW_W}×${VIEW_H} @2x`);
|
||
lines.push(`- Chrome:${chrome.chromeBin}`);
|
||
lines.push(`- palette:${palette.size} 色;字阶:${[...fontScale].sort((a, b) => a - b).join('/')}px;半径 token:${[...radiusTokens].sort((a, b) => a - b).join('/')}px`);
|
||
lines.push(`- 口径:按可见元素的 getComputedStyle 计次(一个元素一次),非源码声明次数;默认未登录态渲染,异步接口失败的页面呈现错误/空态`);
|
||
lines.push('');
|
||
for (const p of analyzed) {
|
||
lines.push(`## ${p.route}`);
|
||
lines.push(`- 可见元素 ${p.elements};横向溢出:${p.horizontalOverflow ? `**是(scrollWidth ${p.scrollWidth} > ${p.innerWidth})**` : '否'};正文文本 ${p.bodyTextLength} 字符`);
|
||
lines.push(`- 控制台错误:${p.consoleErrors.length ? '**' + p.consoleErrors.length + ' 条**' : '0'}`);
|
||
for (const e of p.consoleErrors.slice(0, 5)) lines.push(` - ${e}`);
|
||
lines.push(`- 文字颜色:palette 内 ${p.colors.onPaletteCount} / 外 **${p.colors.offPaletteCount}**(近色 ${p.colors.nearMissCount})`);
|
||
if (p.colors.offPalette.length) {
|
||
lines.push('');
|
||
lines.push(' | 生效文字色(palette 外) | 元素数 | 最近 palette 色 (Δ) |');
|
||
lines.push(' |---|---:|---|');
|
||
for (const d of p.colors.offPalette.slice(0, 8)) {
|
||
const hint = d.framework ? `框架默认(${d.framework})` : d.nearest ? `${d.nearest.value} (Δ${d.nearest.dist})` : '—';
|
||
lines.push(` | ${d.value} | ${d.count} | ${hint} |`);
|
||
}
|
||
lines.push('');
|
||
}
|
||
lines.push(`- 背景色:palette 内 ${p.backgrounds.onPaletteCount} / 外 **${p.backgrounds.offPaletteCount}**(近色 ${p.backgrounds.nearMissCount})`);
|
||
const offBg = p.backgrounds.offPalette.slice(0, 5).map((d) => `${d.value}×${d.count}${d.nearest ? `(→${d.nearest.value} Δ${d.nearest.dist})` : ''}`).join(',');
|
||
if (offBg) lines.push(` - 头部:${offBg}`);
|
||
const offFs = Object.entries(p.fontSizes.offScale).sort((a, b) => b[1] - a[1]);
|
||
lines.push(`- 字号:分布 ${fmtDist(p.fontSizes.distribution)}`);
|
||
lines.push(`- 字阶外:${offFs.length ? offFs.map(([v, c]) => `${v}px×${c}`).join(',') : '无'}`);
|
||
const offR = Object.entries(p.radii.offToken).sort((a, b) => b[1] - a[1]);
|
||
lines.push(`- 圆角 token 外:${offR.length ? offR.map(([v, c]) => `${v}px×${c}`).join(',') : '无'}`);
|
||
lines.push('');
|
||
}
|
||
lines.push('## 截图证据');
|
||
for (const p of analyzed) lines.push(`- output/ui-ux/rendered-shots/${p.route.replaceAll('/', '-')}.png`);
|
||
|
||
mkdirSync(outDir, { recursive: true });
|
||
writeFileSync(path.join(outDir, 'rendered-audit-latest.json'), JSON.stringify({ generatedAt: new Date().toISOString(), baseUrl, viewport: [VIEW_W, VIEW_H], pages: analyzed }, null, 2));
|
||
writeFileSync(path.join(outDir, 'rendered-audit-latest.md'), lines.join('\n') + '\n');
|
||
console.log(`\n报告已写入 ${path.join(outDir, 'rendered-audit-latest.md')}`);
|
||
|
||
if (analyzed.some((p) => p.horizontalOverflow || p.consoleErrors.length)) {
|
||
console.log('提示:存在横向溢出或控制台错误的页面,详见报告。');
|
||
}
|