#!/usr/bin/env node /* 三态主题专项验证:日间 / 夜间 / 自动 + iframe 同步。 */ import { readFileSync } from 'fs'; import { join } from 'path'; const ROOT = process.cwd(); const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311'; let fail = 0; const ok = (condition, message) => { console.log((condition ? 'PASS' : 'FAIL') + ' ' + message); if (!condition) fail++; }; const app = readFileSync(join(ROOT, 'site/app.js'), 'utf8'); const index = readFileSync(join(ROOT, 'site/index.html'), 'utf8'); const css = readFileSync(join(ROOT, '.design_library/kole-ui/colors_and_type.css'), 'utf8'); ok(/prefers-color-scheme/.test(app), '静态:支持 prefers-color-scheme'); ok(/localStorage\.setItem\(['"]kole-mode/.test(app), '静态:持久化 kole-mode'); ok(/data-mode="auto"/.test(index), '静态:站点包含自动模式选项'); ok(/id="topbar-mode"/.test(index), '静态:顶栏含主题模式入口'); /* 顶栏菜单块内三态齐全:块内容取到悬浮菜单 id 之前(两个菜单之间只隔 fab-theme / fab-top 两个按钮), 不设长度窗口 —— 选项之间隔着内联 SVG,窗口大小会随图标改动失真 */ const topbarMenuBlock = index.slice(index.indexOf('id="topbar-mode-menu"'), index.indexOf('id="mode-menu"')); ok(['light', 'dark', 'auto'].every((m) => topbarMenuBlock.includes(`data-mode="${m}"`)), '静态:顶栏菜单三态齐全'); ok(/html\.kole-dark/.test(css), '静态:暗色令牌入口存在'); /* 默认自动:kole-mode 缺省值必须是 auto —— 静态卡住回退成 light 的回归 */ ok(/var MODE_DEFAULT = 'auto'/.test(app), '静态:模式默认值是 auto'); ok(/'kole-mode'\) \|\| 'auto'/.test(index), '静态:解析期 bootstrap 默认 auto'); ok(index.indexOf("|| 'auto'") < index.indexOf("data.js"), '静态:bootstrap 在 data.js 之前执行(无主题闪烁)'); ok(//.test(index), '静态:声明 color-scheme(原生控件跟随)'); import { probeContrast } from './lib/contrast-probe.mjs'; let chromium; try { ({ chromium } = await import('playwright')); } catch { console.error('[FATAL] 未安装 playwright'); process.exit(2); } const browser = await chromium.launch(); try { async function siteCase(colorScheme, storedMode, expectedDark, label) { const page = await browser.newPage({ colorScheme }); await page.addInitScript((mode) => localStorage.setItem('kole-mode', mode), storedMode); await page.goto(BASE + '/site/index.html', { waitUntil: 'load', timeout: 15000 }); await page.waitForTimeout(150); const result = await page.evaluate(() => ({ dark: document.documentElement.classList.contains('kole-dark'), stored: localStorage.getItem('kole-mode'), menu: document.querySelectorAll('#mode-menu [role="radio"]').length, auto: document.querySelector('#mode-menu [data-mode="auto"]')?.getAttribute('aria-checked'), iframe: Array.from(document.querySelectorAll('iframe')).every((f) => { try { return f.contentDocument?.documentElement.classList.contains('kole-dark') === document.documentElement.classList.contains('kole-dark'); } catch { return true; } }) })); ok(result.dark === expectedDark, `${label}:kole-dark=${result.dark}`); ok(result.stored === storedMode, `${label}:kole-mode=${result.stored}`); ok(result.menu === 3 && result.auto === (storedMode === 'auto' ? 'true' : 'false'), `${label}:模式菜单 ARIA`); ok(result.iframe, `${label}:iframe 主题同步`); await page.close(); } await siteCase('light', 'auto', false, '自动 + 系统日间'); await siteCase('dark', 'auto', true, '自动 + 系统夜间'); await siteCase('dark', 'light', false, '手动日间覆盖系统夜间'); await siteCase('light', 'dark', true, '手动夜间覆盖系统日间'); for (const mode of ['light', 'dark']) { await chromeContrastCase(mode, '/site/guide', mode + ' · /site/guide'); await chromeContrastCase(mode, '/site/component/table', mode + ' · /site/component/table'); await chromeContrastCase(mode, '/site/component/button', mode + ' · /site/component/button'); } /* 站点 chrome 的对比度(亮/暗双模式):徽章、警告 callout、代码块、侧栏选中态等 在两种模式下都要过 AA。判据与暗色那份共用 tools/lib/contrast-probe.mjs。 */ async function chromeContrastCase(mode, route, label) { const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); await page.addInitScript((m) => localStorage.setItem('kole-mode', m), mode); const resp = await page.goto(BASE + route, { waitUntil: 'networkidle', timeout: 20000 }).catch(() => null); const status = resp ? resp.status() : 0; ok(status === 200, `${label}:可访问(HTTP ${status})`); if (status !== 200) { await page.close(); return; } const isDark = await page.evaluate(() => document.documentElement.classList.contains('kole-dark')); ok(isDark === (mode === 'dark'), `${label}:kole-dark=${isDark}`); const res = await probeContrast(page); ok(res.bad.length === 0, `${label}:chrome 对比度抽查 ${res.checked} 节点` + (res.bad.length ? ' 不足:' + res.bad.join(';') : '')); await page.close(); } /* 首次访问(无 kole-mode):默认 auto,且跟随系统实时生效 */ const firstRun = await browser.newPage({ colorScheme: 'dark' }); await firstRun.goto(BASE + '/site/index.html', { waitUntil: 'load', timeout: 15000 }); await firstRun.waitForTimeout(150); const first = await firstRun.evaluate(() => { const cur = document.getElementById('mode-current'); return { stored: localStorage.getItem('kole-mode'), dark: document.documentElement.classList.contains('kole-dark'), auto: document.querySelector('#mode-menu [data-mode="auto"]')?.getAttribute('aria-checked'), current: (cur || {}).textContent || '', currentLines: cur ? cur.children.length : 0, currentEm: cur && cur.querySelector('em') ? cur.querySelector('em').textContent : '' }; }); ok(first.stored === 'auto', `首访默认:kole-mode=${first.stored}(落盘默认 auto)`); ok(first.dark === true, '首访默认:系统夜间时生效夜间'); ok(first.auto === 'true', '首访默认:菜单选中自动'); ok(first.currentLines === 2 && first.current.indexOf('自动模式') > -1, `首访默认:菜单头两行("${first.current.trim().replace(/\s+/g, ' ')}")`); ok(first.currentEm.indexOf('夜间') > -1, `首访默认:第二行标出生效主题("${first.currentEm}")`); await firstRun.emulateMedia({ colorScheme: 'light' }); await firstRun.waitForTimeout(120); ok(!(await firstRun.locator('html').evaluate((el) => el.classList.contains('kole-dark'))), '首访默认:系统切日间后即时反色'); await firstRun.close(); const changing = await browser.newPage({ colorScheme: 'light' }); await changing.addInitScript(() => localStorage.setItem('kole-mode', 'auto')); await changing.goto(BASE + '/site/index.html', { waitUntil: 'load', timeout: 15000 }); await changing.waitForTimeout(100); await changing.emulateMedia({ colorScheme: 'dark' }); await changing.waitForTimeout(100); ok(await changing.locator('html').evaluate((el) => el.classList.contains('kole-dark')), '自动模式:系统切换到夜间即时生效'); await changing.emulateMedia({ colorScheme: 'light' }); await changing.waitForTimeout(100); ok(!(await changing.locator('html').evaluate((el) => el.classList.contains('kole-dark'))), '自动模式:系统切换到日间即时生效'); await changing.close(); const menuPage = await browser.newPage({ colorScheme: 'light' }); await menuPage.addInitScript(() => localStorage.removeItem('kole-mode')); await menuPage.goto(BASE + '/site/index.html', { waitUntil: 'load', timeout: 15000 }); await menuPage.locator('#fab-mode').click(); await menuPage.locator('#mode-menu [data-mode="dark"]').click(); ok(await menuPage.evaluate(() => localStorage.getItem('kole-mode') === 'dark' && document.documentElement.classList.contains('kole-dark')), '菜单选择夜间模式'); await menuPage.close(); /* 顶栏选择器:可见、可选、与悬浮菜单互斥、窄屏收成图标 */ const topbar = await browser.newPage({ colorScheme: 'light', viewport: { width: 1280, height: 800 } }); await topbar.addInitScript(() => localStorage.removeItem('kole-mode')); await topbar.goto(BASE + '/site/index.html', { waitUntil: 'load', timeout: 15000 }); await topbar.waitForTimeout(150); ok(await topbar.locator('#topbar-mode').isVisible(), '顶栏入口:默认可⻅'); const topbarLabel = (await topbar.locator('#topbar-mode-label').textContent()).trim(); ok(topbarLabel === '自动模式', `顶栏入口:显示当前模式("${topbarLabel}")`); await topbar.locator('#topbar-mode').click(); ok(await topbar.locator('#topbar-mode-menu').isVisible(), '顶栏入口:点开三态菜单'); ok(await topbar.locator('#topbar-mode-menu [data-mode="auto"]').getAttribute('aria-checked') === 'true', '顶栏菜单:默认选中自动'); await topbar.locator('#topbar-mode-menu [data-mode="dark"]').click(); ok(await topbar.evaluate(() => document.documentElement.classList.contains('kole-dark') && localStorage.getItem('kole-mode') === 'dark'), '顶栏菜单:选夜间即生效并落盘'); ok((await topbar.locator('#topbar-mode-label').textContent()).trim() === '夜间模式', '顶栏入口:模式名同步更新'); /* 两个入口互斥:顶栏开着时点悬浮按钮,顶栏那份必须收起 */ await topbar.locator('#topbar-mode').click(); await topbar.locator('#fab-mode').click(); ok(await topbar.evaluate(() => document.getElementById('topbar-mode-menu').classList.contains('hidden') && !document.getElementById('mode-menu').classList.contains('hidden')), '两个入口互斥:悬浮菜单打开时顶栏菜单收起'); await topbar.keyboard.press('Escape'); ok(await topbar.evaluate(() => document.getElementById('mode-menu').classList.contains('hidden')), 'Esc 关闭菜单'); await topbar.setViewportSize({ width: 900, height: 800 }); await topbar.waitForTimeout(120); const narrow = await topbar.evaluate(() => { const label = document.getElementById('topbar-mode-label'); const trigger = document.getElementById('topbar-mode'); const navToggle = document.getElementById('nav-toggle'); return { labelHidden: getComputedStyle(label).display === 'none', triggerVisible: trigger.getBoundingClientRect().width > 0 && trigger.getBoundingClientRect().right <= window.innerWidth, navToggleVisible: getComputedStyle(navToggle).display !== 'none' }; }); ok(narrow.labelHidden, '窄屏:顶栏模式名收起(仅留图标 + 箭头)'); ok(narrow.triggerVisible, '窄屏:入口仍在顶栏内可见(未被挤出屏幕)'); ok(narrow.navToggleVisible, '窄屏:汉堡按钮未被挤掉'); /* 超窄屏(320px):顶栏只留品牌标记 / 搜索 / 主题 / 汉堡,语言与框架选择器让位。 只量顶栏自身:320px 下首页内容(.stat 统计卡)另有 15px 溢出,与本改动无关, 量整页会把既有问题算到顶栏头上(既有问题已另行记录,不在此处放宽或掩盖)。 */ await topbar.setViewportSize({ width: 375, height: 700 }); await topbar.waitForTimeout(120); const phone = await topbar.evaluate(() => { const shown = (sel) => getComputedStyle(document.querySelector(sel)).display !== 'none'; const r = document.getElementById('topbar-mode').getBoundingClientRect(); return { themeVisible: shown('#theme-select') && r.right <= window.innerWidth, langVisible: shown('#lang-select') }; }); ok(phone.themeVisible, '375px:主题入口在顶栏可见'); ok(phone.langVisible, '375px:语言选择器未被牵连隐藏(常见手机宽度仍可切语言)'); await topbar.setViewportSize({ width: 320, height: 700 }); await topbar.waitForTimeout(120); const tiny = await topbar.evaluate(() => { const inner = document.querySelector('.topbar-inner'); const cs = getComputedStyle(inner); const visible = [...inner.children].filter((el) => { const s = getComputedStyle(el); if (s.display === 'none' || s.position === 'fixed' || s.visibility === 'hidden') return false; return el.getBoundingClientRect().width > 0; }); const need = visible.reduce((a, el) => a + el.getBoundingClientRect().width, 0) + Math.max(0, visible.length - 1) * parseFloat(cs.gap) + parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight); const box = (sel) => document.querySelector(sel).getBoundingClientRect(); const shown = (sel) => getComputedStyle(document.querySelector(sel)).display !== 'none'; return { topbarOverflow: Math.max(0, Math.ceil(need - document.documentElement.clientWidth)), themeVisible: shown('#theme-select') && box('#topbar-mode').right <= window.innerWidth, langHidden: !shown('#lang-select'), fwHidden: !shown('.fw-select-wrap'), navToggleVisible: shown('#nav-toggle') }; }); ok(tiny.topbarOverflow <= 0, `超窄屏 320px:顶栏自身无横向溢出(溢出 ${tiny.topbarOverflow}px)`); ok(tiny.themeVisible, '超窄屏 320px:主题入口仍在顶栏可见'); ok(tiny.langHidden && tiny.fwHidden, '超窄屏 320px:语言与框架选择器让位(保主题入口)'); ok(tiny.navToggleVisible, '超窄屏 320px:汉堡按钮保留'); await topbar.close(); const switcher = await browser.newPage({ colorScheme: 'light' }); await switcher.addInitScript(() => localStorage.removeItem('kole-mode')); await switcher.goto(BASE + '/frameworks/ThemeSwitcher.html', { waitUntil: 'load', timeout: 15000 }); const modes = await switcher.locator('.kole-themesw-opt').count(); ok(modes === 3, 'ThemeSwitcher H5:三种模式'); ok(await switcher.locator('[data-mode="auto"]').getAttribute('aria-checked') === 'true', 'ThemeSwitcher H5:默认自动'); await switcher.locator('[data-mode="dark"]').click(); ok(await switcher.locator('html').evaluate((el) => el.classList.contains('kole-dark')), 'ThemeSwitcher H5:夜间模式生效'); await switcher.locator('[data-mode="auto"]').click(); ok(await switcher.locator('[data-mode="auto"]').getAttribute('aria-checked') === 'true', 'ThemeSwitcher H5:自动模式 ARIA'); await switcher.close(); const pg = await browser.newPage({ colorScheme: 'dark' }); await pg.addInitScript(() => localStorage.removeItem('kole-mode')); await pg.goto(BASE + '/site/playground.html', { waitUntil: 'load', timeout: 15000 }); await pg.waitForTimeout(150); ok(await pg.locator('html').evaluate((el) => el.classList.contains('kole-dark')), 'playground:默认自动 + 系统夜间生效'); await pg.close(); } finally { await browser.close(); } console.log(fail ? `THEME VERIFY FAIL (${fail})` : 'THEME VERIFY OK'); process.exit(fail ? 1 : 0);