// 动效专项通用运行时冒烟(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);