约束纠正: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)
79 lines
3.4 KiB
JavaScript
79 lines
3.4 KiB
JavaScript
// 动效专项通用运行时冒烟(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);
|