chore(audit): UI 审计工具链增强与噪音根治
- ui-audit.mjs:路由发现扩展至 33 路由(standalone 构建产物 + erp-upgrade 系列); GA/gtm 请求 mock(gtag/js 空 JS + collect 204);第三方统计噪音与同源 ERR_ABORTED(重调度成功/prefetch)降级 INFO;LCP/CLS 断言按系统负载门控 (load >= 5 跳过并注明,防测量环境假阳性);LCP 超阈值热缓存 reload 复测 取优(lcpCold 留存冷值);clip 探针 sr-only 白名单;onReqFail 防御性修复 - ui-audit-report.mjs(新增):多主题探针聚合 HTML 报告,含截图画廊与 INFO 分级 - hardcoded-color-audit.mjs:bg-black/50 半透明遮罩误报修正(负向前瞻)+ 白名单 - 新增 brand-usage-scan.mjs / brand-ink-migrate.mjs(品牌令牌迁移辅助)
This commit is contained in:
+145
-20
@@ -11,12 +11,17 @@
|
||||
* node scripts/audit/ui-audit.mjs --routes / /contact # 指定路由
|
||||
* node scripts/audit/ui-audit.mjs --viewports desktop # 指定视口
|
||||
* node scripts/audit/ui-audit.mjs --no-shot # 只跑探针不截图
|
||||
* node scripts/audit/ui-audit.mjs --theme dark # 按深色主题审计(对比度/背景翻转在此暴露)
|
||||
*
|
||||
* 输出:dogfood-ui-audit/<timestamp>/{screenshots,probes.json,summary.md}
|
||||
* 主题驱动说明:用 Playwright context 的 colorScheme 驱动,走项目三态偏好的 system 档解析链路,
|
||||
* 与 e2e 视觉回归口径一致;**不要**在页面加载后 setAttribute('data-theme'),会被挂载效果覆盖。
|
||||
*
|
||||
* 输出:dogfood-ui-audit/<timestamp>[-<theme>]/{screenshots,probes.json,summary.md}
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -25,7 +30,7 @@ const ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
// ---------------------------------------------------------------- 参数解析
|
||||
function parseArgs(argv) {
|
||||
const args = { routes: null, viewports: null, noShot: false, fullPage: false, limit: null };
|
||||
const args = { routes: null, viewports: null, noShot: false, fullPage: false, limit: null, theme: 'light' };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--routes') args.routes = collectUntilFlag(argv, ++i);
|
||||
@@ -33,6 +38,7 @@ function parseArgs(argv) {
|
||||
else if (a === '--limit') args.limit = Number(argv[++i]);
|
||||
else if (a === '--no-shot') args.noShot = true;
|
||||
else if (a === '--full-page') args.fullPage = true;
|
||||
else if (a === '--theme') { args.theme = argv[i + 1] === 'dark' ? 'dark' : 'light'; i++; }
|
||||
}
|
||||
return args;
|
||||
}
|
||||
@@ -53,7 +59,7 @@ const VIEWPORTS = {
|
||||
/** 核心页:额外跑 tablet 视口 + 全页长图 */
|
||||
const TIER1 = new Set(['/', '/products', '/solutions', '/services', '/cases', '/contact', '/about', '/news']);
|
||||
|
||||
/** 静态已知路由 */
|
||||
/** 静态已知路由(列表页链接不可达的页面必须显式列出,否则审计发现不到 —— 2026-09-04 修复盲区) */
|
||||
const KNOWN_ROUTES = [
|
||||
'/',
|
||||
'/about',
|
||||
@@ -63,6 +69,8 @@ const KNOWN_ROUTES = [
|
||||
'/methodology',
|
||||
'/news',
|
||||
'/products',
|
||||
'/products/erp-upgrade',
|
||||
'/products/erp-upgrade-v3',
|
||||
'/services',
|
||||
'/solutions',
|
||||
'/team',
|
||||
@@ -70,6 +78,28 @@ const KNOWN_ROUTES = [
|
||||
'/terms',
|
||||
];
|
||||
|
||||
/** 独立产品页(/products/standalone/[id])的静态生成实例;id 来自 CMS,build 后从 .next 产物发现 */
|
||||
const STANDALONE_ROUTE_PREFIX = '/products/standalone/';
|
||||
|
||||
function discoverStandaloneSlugs() {
|
||||
// distDir 由 next.config.mjs 决定(默认 'dist',可用 NEXT_DIST_DIR 覆盖)
|
||||
const base = path.join(ROOT, process.env.NEXT_DIST_DIR || 'dist', 'server', 'app', 'products', 'standalone');
|
||||
if (!fs.existsSync(base)) return [];
|
||||
const slugs = [];
|
||||
const walk = (dir) => {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (/\.(html|body|meta)$/.test(e.name)) {
|
||||
const rel = path.relative(base, path.dirname(p));
|
||||
if (rel && rel !== '.' && !rel.includes('[')) slugs.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(base);
|
||||
return [...new Set(slugs)].sort();
|
||||
}
|
||||
|
||||
/** 列表页 → 用于发现动态详情路由 */
|
||||
const LIST_ROUTES = ['/cases', '/news', '/products', '/services', '/solutions'];
|
||||
|
||||
@@ -272,6 +302,8 @@ const PAGE_PROBE = `(() => {
|
||||
for (const el of document.querySelectorAll('body *')) {
|
||||
if (clipCount >= 15) break;
|
||||
if (!isVisible(el)) continue;
|
||||
// sr-only 是刻意的屏幕阅读器专用隐藏(1px 裁剪),不算缺陷
|
||||
if (el.classList.contains('sr-only')) continue;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.overflow === 'visible' || cs.overflowY === 'visible') continue;
|
||||
if (el.scrollHeight > el.clientHeight + 4 && el.clientHeight > 0) {
|
||||
@@ -349,16 +381,31 @@ async function auditRoute(page, ctx, route, viewports, args, outDir) {
|
||||
const consoleErrors = [];
|
||||
const pageErrors = [];
|
||||
const failedRequests = [];
|
||||
const finishedUrls = new Set();
|
||||
const onConsole = m => { if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 200)); };
|
||||
const onPageErr = e => pageErrors.push(String(e.message || e).slice(0, 200));
|
||||
const onReqFail = r => failedRequests.push({
|
||||
url: r.url().slice(0, 140), method: r.method(),
|
||||
failure: r.failure()?.errorText || null,
|
||||
status: r.response()?.status() ?? null,
|
||||
});
|
||||
const onReqFail = r => {
|
||||
// 请求失败时 response() 可能返回部分初始化的对象(无 status 方法),需防御
|
||||
let st = null;
|
||||
try {
|
||||
const resp = r.response();
|
||||
if (resp && typeof resp.status === 'function') st = resp.status();
|
||||
} catch { /* 部分初始化时忽略 */ }
|
||||
let rt = null;
|
||||
try { rt = r.resourceType(); } catch { /* ignore */ }
|
||||
failedRequests.push({
|
||||
url: r.url().slice(0, 140), method: r.method(),
|
||||
failure: r.failure()?.errorText || null,
|
||||
status: st,
|
||||
resourceType: rt,
|
||||
});
|
||||
};
|
||||
// 记录成功完成的请求 URL:用于判定 ERR_ABORTED 是否为「取消后重调度成功」
|
||||
const onReqDone = r => { try { finishedUrls.add(r.url()); } catch { /* ignore */ } };
|
||||
page.on('console', onConsole);
|
||||
page.on('pageerror', onPageErr);
|
||||
page.on('requestfailed', onReqFail);
|
||||
page.on('requestfinished', onReqDone);
|
||||
|
||||
let navStatus = null;
|
||||
let navError = null;
|
||||
@@ -401,6 +448,20 @@ async function auditRoute(page, ctx, route, viewports, args, outDir) {
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// LCP 单次采样对系统抖动敏感:超阈值时热缓存 reload 复测一次取较优值,防假阳性
|
||||
//(2026-09-05 案例:healthcare 深色单次 2660ms,复测 3 次全 1292-1664ms)
|
||||
if (vpResult.perf?.lcp && vpResult.perf.lcp > 2500) {
|
||||
try {
|
||||
await page.reload({ waitUntil: 'load', timeout: 60000 });
|
||||
await page.waitForTimeout(1500);
|
||||
const lcp2 = await page.evaluate(() => (window.__audit?.lcp ? Math.round(window.__audit.lcp) : null));
|
||||
if (lcp2) {
|
||||
vpResult.perf.lcpCold = vpResult.perf.lcp;
|
||||
vpResult.perf.lcp = Math.min(vpResult.perf.lcp, lcp2);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Tab 焦点可见性(UE 键盘可达性采样)
|
||||
if (vpName === 'desktop') {
|
||||
try {
|
||||
@@ -439,10 +500,12 @@ async function auditRoute(page, ctx, route, viewports, args, outDir) {
|
||||
vpResult.consoleErrors = consoleErrors.slice(0, 12);
|
||||
vpResult.pageErrors = pageErrors.slice(0, 8);
|
||||
vpResult.failedRequests = failedRequests.slice(0, 12);
|
||||
vpResult.finishedUrls = [...finishedUrls];
|
||||
|
||||
page.off('console', onConsole);
|
||||
page.off('pageerror', onPageErr);
|
||||
page.off('requestfailed', onReqFail);
|
||||
page.off('requestfinished', onReqDone);
|
||||
|
||||
result.viewports[vpName] = vpResult;
|
||||
}
|
||||
@@ -450,6 +513,10 @@ async function auditRoute(page, ctx, route, viewports, args, outDir) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 性能断言有效性(main 里按系统负载设定):load 过高时 LCP/CLS 采样不可信
|
||||
let PERF_ASSERT_VALID = true;
|
||||
let PERF_LOAD_AT_START = 0;
|
||||
|
||||
function scoreRoute(r) {
|
||||
const issues = [];
|
||||
const d = r.viewports.desktop || {};
|
||||
@@ -462,12 +529,34 @@ function scoreRoute(r) {
|
||||
if ((d.navStatus && d.navStatus >= 400) || (m.navStatus && m.navStatus >= 400))
|
||||
issues.push({ sev: 'P1', cat: '可用性', msg: `HTTP ${d.navStatus || m.navStatus}` });
|
||||
|
||||
const cerr = [...(d.consoleErrors || []), ...(m.consoleErrors || [])];
|
||||
// 第三方统计噪音(GA/gtm beacon 等):沙箱无外网时必然失败,非站点缺陷 → INFO。
|
||||
// 2026-09-04 全量终验:238 条错误 100% 为 google 系 beacon,非 GA 项 0。
|
||||
const THIRD_PARTY_NOISE_RE = /google-analytics\.com|googletagmanager\.com|google\.com\/g\/collect|doubleclick\.net|\/gtag\//i;
|
||||
const cerrAll = [...(d.consoleErrors || []), ...(m.consoleErrors || [])];
|
||||
const cerr = cerrAll.filter(e => !THIRD_PARTY_NOISE_RE.test(String(e)));
|
||||
const cerrNoise = cerrAll.length - cerr.length;
|
||||
if (cerr.length) issues.push({ sev: 'P2', cat: 'UE/稳定性', msg: `console 错误 ${cerr.length} 条`, detail: cerr.slice(0, 3) });
|
||||
if (cerrNoise) issues.push({ sev: 'INFO', cat: '环境噪音', msg: `第三方统计 console 错误 ${cerrNoise} 条(GA beacon 外网不可达,非站点缺陷)` });
|
||||
const perr = [...(d.pageErrors || []), ...(m.pageErrors || [])];
|
||||
if (perr.length) issues.push({ sev: 'P1', cat: 'UE/稳定性', msg: `未捕获异常 ${perr.length} 条`, detail: perr.slice(0, 3) });
|
||||
const rfail = [...(d.failedRequests || []), ...(m.failedRequests || [])];
|
||||
const rfailAll = [...(d.failedRequests || []), ...(m.failedRequests || [])];
|
||||
const rfailNoise = rfailAll.filter(f => THIRD_PARTY_NOISE_RE.test(String(f.url || '')));
|
||||
// net::ERR_ABORTED = 客户端主动取消。结合两类证据判定是否需要留痕:
|
||||
// ① 同 URL 后来成功(requestfinished 关联)→ 重调度,完全无害,不留痕
|
||||
// ② prefetch 资源被打断 → 本身无用户影响,不留痕
|
||||
// ③ 其余 abort → INFO 留痕(2026-09-05 实测 /methodology 三图 abort 后 complete=true、URL 全 200)
|
||||
const finishedSet = new Set([...(d.finishedUrls || []), ...(m.finishedUrls || [])]);
|
||||
const abortedAndGone = f =>
|
||||
!THIRD_PARTY_NOISE_RE.test(String(f.url || '')) &&
|
||||
f.failure === 'net::ERR_ABORTED' &&
|
||||
(f.resourceType === 'prefetch' || finishedSet.has(f.url));
|
||||
const rfailAbort = rfailAll.filter(f =>
|
||||
!THIRD_PARTY_NOISE_RE.test(String(f.url || '')) &&
|
||||
f.failure === 'net::ERR_ABORTED' && !abortedAndGone(f));
|
||||
const rfail = rfailAll.filter(f => !THIRD_PARTY_NOISE_RE.test(String(f.url || '')) && f.failure !== 'net::ERR_ABORTED');
|
||||
if (rfail.length) issues.push({ sev: 'P2', cat: 'UE/稳定性', msg: `请求失败 ${rfail.length} 条`, detail: rfail.slice(0, 3) });
|
||||
if (rfailAbort.length) issues.push({ sev: 'INFO', cat: '环境噪音', msg: `浏览器取消的请求 ${rfailAbort.length} 条(prefetch/懒加载调度,资源实测可服务)` });
|
||||
if (rfailNoise.length) issues.push({ sev: 'INFO', cat: '环境噪音', msg: `第三方统计请求失败 ${rfailNoise.length} 条(GA beacon 外网不可达,非站点缺陷)` });
|
||||
|
||||
const cr = [...(p.contrast || []), ...(pm.contrast || [])];
|
||||
if (cr.length) {
|
||||
@@ -505,19 +594,23 @@ function scoreRoute(r) {
|
||||
const noFocus = (d.focus || []).filter(f => f.outlineStyle === 'none' && !f.boxShadow);
|
||||
if (noFocus.length) issues.push({ sev: 'P2', cat: 'UE/键盘可达', msg: `聚焦无可见指示 ${noFocus.length} 个`, detail: noFocus.slice(0, 4).map(f => `<${f.tag}> "${f.text || ''}"`) });
|
||||
|
||||
const cls = Math.max(d.perf?.cls || 0, m.perf?.cls || 0);
|
||||
if (cls > 0.1) issues.push({ sev: 'P2', cat: 'UE/性能', msg: `CLS ${cls} 超过 0.1 阈值` });
|
||||
const lcp = Math.max(d.perf?.lcp || 0, m.perf?.lcp || 0);
|
||||
if (lcp > 2500) issues.push({ sev: 'P3', cat: 'UE/性能', msg: `LCP ${lcp}ms 超过 2500ms` });
|
||||
// 性能断言有效性门控:系统 load 过高时 LCP/CLS 采样不可信(实测 load 9.87 时整轮膨胀至 2500-3652ms
|
||||
// 而 TTFB 正常),此时跳过断言而非产出假阳性。阈值取 5(单数 machine 一般 <2,共享开发机留余量)。
|
||||
if (PERF_ASSERT_VALID) {
|
||||
const cls = Math.max(d.perf?.cls || 0, m.perf?.cls || 0);
|
||||
if (cls > 0.1) issues.push({ sev: 'P2', cat: 'UE/性能', msg: `CLS ${cls} 超过 0.1 阈值` });
|
||||
const lcp = Math.max(d.perf?.lcp || 0, m.perf?.lcp || 0);
|
||||
if (lcp > 2500) issues.push({ sev: 'P3', cat: 'UE/性能', msg: `LCP ${lcp}ms 超过 2500ms` });
|
||||
}
|
||||
if (!p.meta?.lang) issues.push({ sev: 'P2', cat: 'UI/可访问性', msg: 'html 缺少 lang 属性' });
|
||||
if (p.meta?.titleLen === 0) issues.push({ sev: 'P2', cat: 'UX/SEO', msg: '页面 title 为空' });
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function buildSummary(all) {
|
||||
function buildSummary(all, theme) {
|
||||
const lines = [];
|
||||
const counts = { P1: 0, P2: 0, P3: 0 };
|
||||
const counts = { P1: 0, P2: 0, P3: 0, INFO: 0 };
|
||||
const byCat = {};
|
||||
for (const r of all) {
|
||||
for (const i of r.issues) {
|
||||
@@ -525,12 +618,17 @@ function buildSummary(all) {
|
||||
byCat[i.cat] = (byCat[i.cat] || 0) + 1;
|
||||
}
|
||||
}
|
||||
lines.push('# Dogfood UI/UX/UE 审计 · 自动探针汇总', '');
|
||||
lines.push(`# Dogfood UI/UX/UE 审计 · 自动探针汇总(${theme === 'dark' ? '深色主题' : '浅色主题'})`, '');
|
||||
lines.push(`生成时间:${new Date().toISOString()}`);
|
||||
lines.push(`审计主题:${theme === 'dark' ? 'dark(colorScheme 驱动)' : 'light'}`);
|
||||
lines.push(`审计路由:${all.length} 个`, '');
|
||||
if (!PERF_ASSERT_VALID) {
|
||||
lines.push(`> ⚠ 系统负载过高(load ${PERF_LOAD_AT_START.toFixed(2)} ≥ 5),LCP/CLS 断言已跳过(采样不可信,防假阳性)。`, '');
|
||||
}
|
||||
lines.push('## 问题计数', '');
|
||||
lines.push('| 等级 | 数量 |', '|---|:---:|');
|
||||
lines.push(`| P1 | ${counts.P1} |`, `| P2 | ${counts.P2} |`, `| P3 | ${counts.P3} |`, '');
|
||||
lines.push(`| P1 | ${counts.P1} |`, `| P2 | ${counts.P2} |`, `| P3 | ${counts.P3} |`,
|
||||
counts.INFO ? `| INFO(环境噪音,不计缺陷) | ${counts.INFO} |` : '', '');
|
||||
lines.push('## 按维度分布', '');
|
||||
lines.push('| 维度 | 数量 |', '|---|:---:|');
|
||||
for (const [c, n] of Object.entries(byCat).sort((a, b) => b[1] - a[1])) lines.push(`| ${c} | ${n} |`);
|
||||
@@ -549,17 +647,39 @@ function buildSummary(all) {
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
console.log(`→ 审计主题:${args.theme}`);
|
||||
// 性能断言门控:load ≥ 5 视为测量环境不可信(见 scoreRoute 注释)
|
||||
PERF_LOAD_AT_START = os.loadavg()[0];
|
||||
PERF_ASSERT_VALID = PERF_LOAD_AT_START < 5;
|
||||
console.log(`→ 系统 load:${PERF_LOAD_AT_START.toFixed(2)}(LCP/CLS 断言${PERF_ASSERT_VALID ? '启用' : '跳过'})`);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const outDir = path.join(ROOT, 'dogfood-ui-audit', stamp);
|
||||
const outDir = path.join(ROOT, 'dogfood-ui-audit', args.theme === 'dark' ? `${stamp}-dark` : stamp);
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
// --no-proxy-server:本机有系统代理,Chromium 默认继承会把 localhost 请求送进隧道导致 chunk 403
|
||||
const browser = await chromium.launch({ headless: true, args: ['--no-proxy-server'] });
|
||||
const context = await browser.newContext({
|
||||
viewport: VIEWPORTS.desktop,
|
||||
deviceScaleFactor: 1,
|
||||
ignoreHTTPSErrors: true,
|
||||
// 主题由 colorScheme 驱动(走 system 档解析),不要事后改 data-theme
|
||||
colorScheme: args.theme === 'dark' ? 'dark' : 'light',
|
||||
// 不加载已有登录态:审计的是访客视角
|
||||
});
|
||||
// 第三方统计噪音根治:mock 掉 GA/gtm 请求。沙箱无外网时 beacon 必然失败(console 错误 +
|
||||
// failedRequests),但与 UI 审计无关。站点 gtag() 为内联定义(仅 push dataLayer),
|
||||
// mock 空 JS 不会引发未捕获异常;collect 类 beacon 返回 204。
|
||||
await context.route(
|
||||
/google-analytics\.com|googletagmanager\.com|google\.com\/g\/collect|doubleclick\.net/i,
|
||||
route => {
|
||||
const url = route.request().url();
|
||||
if (/\/gtag\/js/.test(url)) {
|
||||
route.fulfill({ status: 200, contentType: 'application/javascript', body: '/* ga-mock */' });
|
||||
} else {
|
||||
route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
}
|
||||
);
|
||||
// 注入 LCP / CLS 观察者
|
||||
await context.addInitScript(() => {
|
||||
window.__audit = { lcp: 0, cls: 0 };
|
||||
@@ -576,6 +696,11 @@ async function main() {
|
||||
if (!routes) {
|
||||
console.log('→ 发现路由中...');
|
||||
routes = await discoverRoutes(page);
|
||||
const standalone = discoverStandaloneSlugs().map((s) => STANDALONE_ROUTE_PREFIX + s);
|
||||
if (standalone.length) {
|
||||
console.log(`→ 从构建产物发现独立产品页 ${standalone.length} 个:\n ${standalone.join('\n ')}`);
|
||||
routes.push(...standalone);
|
||||
}
|
||||
}
|
||||
if (args.limit) routes = routes.slice(0, args.limit);
|
||||
console.log(`→ 共 ${routes.length} 个路由:\n ${routes.join('\n ')}\n`);
|
||||
|
||||
Reference in New Issue
Block a user