Files
novalon-website/scripts/audit/motion-smoke.mjs
T
zhangxiang 5159862e96 fix(motion): ScrollReveal 默认入场 400ms→300ms,对齐动效四原则目标值
约束纠正:CONTEXT.md L49-61 规定入场 200-300ms 为**目标值**,700ms 只是
「禁止超过」的**硬上限**(L58)。此前按 700ms 执行属最低限度合规。

杠杆点改动:
- src/components/ui/scroll-reveal.tsx:69 duration 0.4 → 0.3
  该默认值被 22 个文件消费,一行改动即把全站默认入场拉到目标区间
- 同文件 staggerDelay/delayChildren = 0.05 已在 30-60ms 目标内,未动

新增审计工具(Task #16 各批次复用):
- scripts/audit/motion-duration-audit.mjs:扫 273 文件,抓 framer duration /
  默认参数 / Tailwind duration-NNN / staggerDelay·delayChildren·delay,
  按 P0(>700ms) / P1(300-700ms) / P2(stagger>60ms) 分级
  - 已修正两处自身缺陷:漏掉默认参数赋值 duration=N;CountUp/toast 的
    毫秒值误判为秒(加 >10 判毫秒排除 7 处误报)
- scripts/audit/motion-smoke.mjs:多路由 × 浅深运行时冒烟(hydration + 0 报错)

盘点基线(本次审计):P0 硬违规 99 / P1 超目标 164 / P2 stagger 28
- 其中 animations.tsx 20 处为死代码(生产零消费方,仅自家 test import),
  按「保留待用」决策不动,不计入用户可见违规 → 用户可见 P0 89 / P1 157 / P2 25

验证:tsc 0 错 · eslint 0 错(50 既有 warnings) · jest 129 套件/1628 通过
      冒烟 5 路由 × 浅深 = 10/10 PASS(hydration=true, errors=0)
2026-09-20 11:50:58 +08:00

79 lines
3.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 动效专项通用运行时冒烟(Task #16 各批次复用)
//
// 用途:验证批次改动后页面不破坏——hydration 成功、hero 渲染、0 控制台报错。
// 说明:时长类改动(如 400ms→300ms)的**终态不变**,截图无法体现差异,
// 故本脚本不做截图比对,只验证运行时完整性与无回归报错。
// (项目约定:网页任务保证编译通过 + 冒烟,不做额外视觉校验)
//
// 环境约束(2026-09-02 踩坑固化):
// - 必须 chromium.launch({ args: ['--no-proxy-server'] }):本机系统代理会拦 403
// - 必须 localhost(非 127.0.0.1):Next dev allowedDevOrigins 白名单不含 127.0.0.1
// 否则 _next/static/chunks/*.js 被 403 → framer-motion 加载失败 → hydration 从不发生
//
// CLI: node scripts/audit/motion-smoke.mjs [--routes / /products/erp] [--dark]
import { chromium } from 'playwright';
import { writeFileSync, mkdirSync } from 'node:fs';
const args = process.argv.slice(2);
const getList = (flag, def) => {
const i = args.indexOf(flag);
return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1].split(',') : def;
};
const ROUTES = getList('--routes', ['/', '/products/erp', '/solutions', '/about']);
const WITH_DARK = args.includes('--dark');
const BASE = 'http://localhost:3000';
const OUT = 'dogfood-motion-audit';
mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({ args: ['--no-proxy-server'] });
async function check(path, theme) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
if (theme === 'dark') {
await page.addInitScript(() => {
try { localStorage.setItem('novalon-theme', 'dark'); } catch {}
});
}
const errors = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push('PAGEERROR: ' + e.message));
await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 45000 });
// hydration 探测:framer-motion 跑起来后会有元素 opacity 达到 1
let hydrated = false;
try {
await page.waitForFunction(() => {
const els = Array.from(document.querySelectorAll('div[style*="opacity"]'));
return els.some((el) => getComputedStyle(el).opacity === '1');
}, { timeout: 12000 });
hydrated = true;
} catch { hydrated = false; }
const h1 = await page.locator('h1').first().innerText().catch(() => '');
await ctx.close();
return { path, theme, hydrated, h1: h1.slice(0, 30), errors };
}
const results = [];
for (const r of ROUTES) {
results.push(await check(r, 'light'));
if (WITH_DARK) results.push(await check(r, 'dark'));
}
await browser.close();
const failed = results.filter((r) => !r.hydrated || r.errors.length > 0 || !r.h1);
const report = { generatedAt: new Date().toISOString(), routes: ROUTES, results, failedCount: failed.length };
writeFileSync(`${OUT}/motion-smoke.json`, JSON.stringify(report, null, 2));
for (const r of results) {
const ok = r.hydrated && r.errors.length === 0 && r.h1 ? 'PASS' : 'FAIL';
console.log(`${ok} ${r.theme.padEnd(5)} ${r.path.padEnd(18)} hydrated=${r.hydrated} errors=${r.errors.length} h1="${r.h1}"`);
r.errors.slice(0, 3).forEach((e) => console.log(` └ ${e.slice(0, 160)}`));
}
console.log(`\n${results.length - failed.length}/${results.length} PASS`);
process.exit(failed.length > 0 ? 1 : 0);