// 跟随系统自动切换暗黑模式 —— 运行时验证 // // 验证「三档偏好 + 首帧无闪烁 + 实时跟随」在真实浏览器里的行为: // 1) 首帧(domcontentloaded,早于 hydration)data-theme 已由 内联脚本写对 // 2) 三档循环 system → light → dark → system,DOM 与 localStorage 同步 // 3) 仅「跟随系统」档实时响应 OS 偏好变化;显式选择不被系统覆盖 // 4) 旧存储值(light / dark)向后兼容,污染值回退跟随系统 // 5) CSS 变量确实翻转(不只看属性,看计算后的背景色) // 6) hydration 成功、无控制台报错 // // 环境约束(2026-09-02):URL 必须用 localhost,127.0.0.1 会被 Next dev 的 // allowedDevOrigins 403 → chunk 加载失败 → hydration 从不发生。 // // 用法:node scripts/audit/theme-system-verify.mjs // 前置:dev server 已在 http://localhost:3000 运行 import { chromium } from 'playwright'; import { writeFileSync, mkdirSync } from 'node:fs'; const BASE = 'http://localhost:3000'; const OUT = 'dogfood-output/theme-system-verify'; mkdirSync(OUT, { recursive: true }); const browser = await chromium.launch({ args: ['--no-proxy-server'] }); /** 开一个带指定系统偏好的页面;storedPreference 为 null 时不预置 localStorage */ async function openPage(colorScheme, storedPreference) { const ctx = await browser.newContext({ colorScheme, viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); if (storedPreference) { await page.addInitScript((value) => { try { localStorage.setItem('novalon-theme', value); } catch { /* 隐私模式兜底 */ } }, storedPreference); } const errors = []; page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); page.on('pageerror', (e) => errors.push('PAGEERROR: ' + e.message)); return { ctx, page, errors }; } /** 读取主题相关探针 */ async function probe(page) { return page.evaluate(() => { const html = document.documentElement; const svg = document.querySelector('[data-testid="theme-toggle"] svg'); return { dataTheme: html.getAttribute('data-theme'), preference: document .querySelector('[data-testid="theme-toggle"]') ?.getAttribute('data-theme-preference') ?? null, // class 形如 "lucide lucide-sun w-4 h-4",只取 lucide-* 图标名 icon: svg ? ((svg.getAttribute('class') || '').match(/lucide-[a-z-]+/) || [null])[0] : null, bodyBg: getComputedStyle(document.body).backgroundColor, stored: (() => { try { return localStorage.getItem('novalon-theme'); } catch { return null; } })(), label: document .querySelector('[data-testid="theme-toggle"]') ?.getAttribute('aria-label') ?? null, }; }); } async function waitHydrated(page) { try { await page.waitForFunction( () => { const els = Array.from(document.querySelectorAll('div[style*="opacity"]')); return els.some((el) => getComputedStyle(el).opacity === '1'); }, { timeout: 10000 }, ); return true; } catch { return false; } } /** * 等待 ThemeToggle 完成客户端解析(挂载后 effect 写入 data-theme-preference 并渲染图标)。 * 首屏可能是 dev server 冷编译, hydration 会比后续导航慢得多, * 因此这里直接等「组件状态就绪」这一语义信号,而不是等通用的动效探针。 */ async function waitToggleReady(page) { try { await page.waitForFunction( () => document .querySelector('[data-testid="theme-toggle"]') ?.getAttribute('data-theme-preference') != null, { timeout: 30000 }, ); return true; } catch { return false; } } const results = []; const failures = []; /** 记录两个场景下的 body 背景色,用于断言 CSS 变量真的翻转了 */ const bodyBgByScheme = {}; function record(name, expected, actual, extra = {}) { const pass = JSON.stringify(expected) === JSON.stringify(actual); if (!pass) failures.push({ name, expected, actual }); results.push({ name, pass, expected, actual, ...extra }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}\n expected=${JSON.stringify(expected)}\n actual =${JSON.stringify(actual)}`); } // ── 场景 1/2:默认跟随系统(未做过显式选择) ───────────────────────────── for (const scheme of ['dark', 'light']) { const { ctx, page, errors } = await openPage(scheme, null); await page.goto(BASE + '/', { waitUntil: 'domcontentloaded', timeout: 30000 }); // 关键:此时早于 hydration,data-theme 若已正确 ⇒ 内联首帧脚本生效,无闪烁 const firstPaint = await probe(page); const hydrated = await waitHydrated(page); const toggleReady = await waitToggleReady(page); const after = await probe(page); await page.screenshot({ path: `${OUT}/default-system-${scheme}.png` }); record(`默认跟随系统(OS=${scheme})hydration 完成`, true, hydrated); record(`默认跟随系统(OS=${scheme})切换器状态就绪`, true, toggleReady); record(`默认跟随系统(OS=${scheme})首帧 data-theme`, scheme, firstPaint.dataTheme); record(`默认跟随系统(OS=${scheme})不写 localStorage`, null, firstPaint.stored); record(`默认跟随系统(OS=${scheme})偏好档位`, 'system', after.preference); record(`默认跟随系统(OS=${scheme})图标`, 'lucide-monitor', after.icon); record(`默认跟随系统(OS=${scheme})hydration 后一致`, scheme, after.dataTheme); bodyBgByScheme[scheme] = after.bodyBg; results.push({ name: `OS=${scheme} body 背景色`, pass: true, bodyBg: after.bodyBg, errors }); if (errors.length) failures.push({ name: `OS=${scheme} 控制台报错`, errors }); await ctx.close(); } // CSS 变量确实翻转:默认跟随系统时,OS 深浅两种偏好应产出不同的 body 背景色 record( '深/浅 body 背景色不同(CSS 变量确实翻转)', true, Boolean(bodyBgByScheme.dark && bodyBgByScheme.light && bodyBgByScheme.dark !== bodyBgByScheme.light), { dark: bodyBgByScheme.dark, light: bodyBgByScheme.light }, ); // ── 场景 3/4:旧存储值向后兼容(显式选择压过系统偏好) ─────────────────── for (const [stored, scheme] of [['dark', 'light'], ['light', 'dark']]) { const { ctx, page, errors } = await openPage(scheme, stored); await page.goto(BASE + '/', { waitUntil: 'domcontentloaded', timeout: 30000 }); const first = await probe(page); await waitHydrated(page); await waitToggleReady(page); const after = await probe(page); record(`旧存储值 ${stored} 压过 OS=${scheme}`, stored, first.dataTheme); record(`旧存储值 ${stored} 偏好档位`, stored, after.preference); if (errors.length) failures.push({ name: `旧存储值 ${stored} 控制台报错`, errors }); await ctx.close(); } // ── 场景 5:污染值回退跟随系统 ────────────────────────────────────────── { const { ctx, page, errors } = await openPage('dark', 'not-a-theme'); await page.goto(BASE + '/', { waitUntil: 'domcontentloaded', timeout: 30000 }); await waitHydrated(page); await waitToggleReady(page); const after = await probe(page); record('污染存储值回退跟随系统(OS=dark)', 'dark', after.dataTheme); record('污染存储值档位为 system', 'system', after.preference); if (errors.length) failures.push({ name: '污染值控制台报错', errors }); await ctx.close(); } // ── 场景 6:三档循环切换 ──────────────────────────────────────────────── { const { ctx, page, errors } = await openPage('dark', null); await page.goto(BASE + '/', { waitUntil: 'networkidle', timeout: 30000 }); await waitHydrated(page); await waitToggleReady(page); const toggle = page.locator('[data-testid="theme-toggle"]'); // 起点:档位 system + OS=dark ⇒ data-theme=dark。显式档压过系统偏好, // 故 light 档在 OS=dark 下仍解析为 light。 const sequence = [ { pref: 'light', theme: 'light', icon: 'lucide-sun' }, { pref: 'dark', theme: 'dark', icon: 'lucide-moon' }, { pref: 'system', theme: 'dark', icon: 'lucide-monitor' }, ]; for (const [i, step] of sequence.entries()) { await toggle.click(); await page.waitForTimeout(150); const p = await probe(page); record(`循环第 ${i + 1} 次点击 → 档位`, step.pref, p.preference); record(`循环第 ${i + 1} 次点击 → data-theme(OS=dark)`, step.theme, p.dataTheme); record(`循环第 ${i + 1} 次点击 → 图标`, step.icon, p.icon); } await page.screenshot({ path: `${OUT}/cycle-clicks.png` }); if (errors.length) failures.push({ name: '循环切换控制台报错', errors }); await ctx.close(); } // ── 场景 7:实时跟随(system 档,OS 在运行中切换) ────────────────────── { const { ctx, page, errors } = await openPage('dark', null); await page.goto(BASE + '/', { waitUntil: 'networkidle', timeout: 30000 }); await waitHydrated(page); await waitToggleReady(page); record('实时跟随便前(OS=dark)', 'dark', (await probe(page)).dataTheme); await page.emulateMedia({ colorScheme: 'light' }); await page.waitForTimeout(300); record('实时跟随:OS 切浅色后不刷新即跟随', 'light', (await probe(page)).dataTheme); await page.emulateMedia({ colorScheme: 'dark' }); await page.waitForTimeout(300); record('实时跟随:OS 切回深色后跟随', 'dark', (await probe(page)).dataTheme); await page.screenshot({ path: `${OUT}/live-follow.png` }); if (errors.length) failures.push({ name: '实时跟随控制台报错', errors }); await ctx.close(); } // ── 场景 8:已显式选择则不被系统覆盖 ──────────────────────────────────── { const { ctx, page, errors } = await openPage('dark', 'light'); await page.goto(BASE + '/', { waitUntil: 'networkidle', timeout: 30000 }); await waitHydrated(page); await waitToggleReady(page); await page.emulateMedia({ colorScheme: 'light' }); await page.waitForTimeout(300); record('显式浅色:OS 切浅色仍为浅色', 'light', (await probe(page)).dataTheme); await page.emulateMedia({ colorScheme: 'dark' }); await page.waitForTimeout(300); record('显式浅色:OS 切深色不被覆盖', 'light', (await probe(page)).dataTheme); if (errors.length) failures.push({ name: '显式覆盖控制台报错', errors }); await ctx.close(); } // ── 场景 9:多路由冒烟(OS=dark 未选过 ⇒ 每个路由首帧/挂载后都应为 dark) ── // 覆盖四层叙事模型的主要页面形态(Hub / Spoke / 详情 / 独立页),确认 // 内联脚本 + ThemeToggle 在整站路由上行为一致、hydration 无报错。 { const routes = ['/products', '/solutions', '/services', '/about', '/contact', '/methodology']; for (const route of routes) { const { ctx, page, errors } = await openPage('dark', null); await page.goto(BASE + route, { waitUntil: 'domcontentloaded', timeout: 30000 }); const firstPaint = await probe(page); const toggleReady = await waitToggleReady(page); const after = await probe(page); record(`${route} 首帧 data-theme`, 'dark', firstPaint.dataTheme); record(`${route} 切换器状态就绪`, true, toggleReady); record(`${route} 挂载后偏好档位`, 'system', after.preference); if (errors.length) failures.push({ name: `${route} 控制台报错`, errors }); await ctx.close(); } } await browser.close(); const summary = { 通过: results.filter((r) => r.pass !== false).length, 断言总数: results.filter((r) => r.pass !== undefined).length, 失败: failures, 明细: results, }; writeFileSync(`${OUT}/theme-system-verify.json`, JSON.stringify(summary, null, 2)); console.log('\n──────── 汇总 ────────'); console.log(`断言 ${summary.断言总数} 条,失败 ${failures.length} 条`); console.log(`产物:${OUT}/(json + 截图)`); if (failures.length) { console.log('\n失败明细:'); failures.forEach((f) => console.log(JSON.stringify(f, null, 2))); process.exitCode = 1; }