import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import vm from 'node:vm'; import vue from '@vitejs/plugin-vue'; import { createServer } from 'vite'; import { createSSRApp, h } from 'vue'; import { renderToString } from 'vue/server-renderer'; const server = await createServer({ configFile: false, plugins: [vue()], resolve: { alias: { '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), }, }, server: { middlewareMode: true, hmr: false, watch: null }, appType: 'custom', }); function browserState(cookie, stored, dark, storageDenied = false) { const root = { dataset: { appearance: cookie }, style: {}, dark: false }; root.classList = { toggle: (_, value) => { root.dark = value; }, }; const listeners = new Map(); const storage = new Map(stored === null ? [] : [['appearance', stored]]); const media = { matches: dark, addEventListener: (_, listener) => listeners.set('media', listener), removeEventListener: () => listeners.delete('media'), }; return { document: { documentElement: root, cookie: '' }, window: { matchMedia: () => media, localStorage: { getItem: (key) => { if (storageDenied) { throw new Error('Storage denied'); } return storage.get(key) ?? null; }, setItem: (key, value) => { if (storageDenied) { throw new Error('Storage denied'); } storage.set(key, value); }, }, addEventListener: (_, listener) => listeners.set('storage', listener), removeEventListener: () => listeners.delete('storage'), }, root, media, listeners, storage, }; } try { const { createAppearance, normalizeAppearance } = await server.ssrLoadModule( '/resources/js/composables/useAppearance.ts', ); const { createLocalization } = await server.ssrLoadModule( '/resources/js/lib/localization.ts', ); const { default: AppearanceTabs } = await server.ssrLoadModule( '/resources/js/components/AppearanceTabs.vue', ); const renderTheme = (value) => renderToString( createSSRApp({ render: () => h(AppearanceTabs) }) .use(createLocalization()) .use(createAppearance(value)), ); const [light, dark] = await Promise.all([ renderTheme('light'), renderTheme('dark'), ]); const selectedButton = (html) => html.match( /]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/, )[1]; assert.match(selectedButton(light), />LightDark([\s\S]*?)<\/script>/)[1]; for (const [cookie, stored, osDark, denied, expected, preference] of [ ['system', null, false, false, false, 'system'], ['system', null, true, false, true, 'system'], ['dark', null, false, false, true, 'dark'], ['light', null, true, false, false, 'light'], ['light', 'dark', false, false, true, 'dark'], ['dark', null, false, true, true, 'dark'], ['light', 'invalid', true, false, true, 'system'], ]) { const state = browserState(cookie, stored, osDark, denied); vm.runInNewContext(bootstrap, state); assert.equal(state.root.dark, expected); assert.equal(state.root.dataset.appearance, preference); assert.equal(state.root.style.colorScheme, expected ? 'dark' : 'light'); } for (const denied of [false, true]) { const state = browserState('system', null, false, denied); globalThis.window = state.window; globalThis.document = state.document; const theme = createAppearance(); let cleanup; theme.install({ provide() {}, onUnmount(callback) { cleanup = callback; }, }); assert.equal(theme.resolvedAppearance.value, 'light'); state.listeners.get('media')({ matches: true }); assert.equal(theme.resolvedAppearance.value, 'dark'); assert.equal(state.root.dark, true); theme.updateAppearance('light'); assert.equal(state.root.dark, false); assert.match(state.document.cookie, /appearance=light;.*SameSite=Lax/); state.listeners.get('media')({ matches: false }); state.listeners.get('media')({ matches: true }); assert.equal(theme.resolvedAppearance.value, 'light'); theme.updateAppearance('system'); assert.equal(theme.resolvedAppearance.value, 'dark'); state.listeners.get('storage')({ key: 'appearance', newValue: 'light', }); assert.equal(theme.appearance.value, 'light'); state.listeners.get('storage')({ key: null, newValue: null }); assert.equal(theme.appearance.value, 'system'); if (!denied) { assert.equal(state.storage.get('appearance'), 'system'); } cleanup(); assert.equal(state.listeners.size, 0); delete globalThis.window; delete globalThis.document; } const css = await readFile('resources/css/theme.css', 'utf8'); function luminance(hex) { const values = hex .match(/[a-f\d]{2}/gi) .map((part) => parseInt(part, 16) / 255) .map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4, ); return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722; } function contrast(first, second) { const values = [luminance(first), luminance(second)].sort( (a, b) => b - a, ); return (values[0] + 0.05) / (values[1] + 0.05); } for (const selector of [':root', '.dark']) { const declarations = css .slice(css.indexOf(selector)) .match(/\{([^}]+)\}/)[1]; const tokens = Object.fromEntries( [...declarations.matchAll(/--([\w-]+):\s*(#[a-f\d]{6})/gi)].map( (match) => [match[1], match[2]], ), ); for (const [background, foreground] of [ ['background', 'foreground'], ['card', 'card-foreground'], ['popover', 'popover-foreground'], ['primary', 'primary-foreground'], ['secondary', 'secondary-foreground'], ['muted', 'muted-foreground'], ['accent', 'accent-foreground'], ['destructive', 'destructive-foreground'], ['card', 'destructive'], ['brand', 'brand-foreground'], ['success', 'success-foreground'], ['warning', 'warning-foreground'], ['info', 'info-foreground'], ['sidebar-background', 'sidebar-foreground'], ['sidebar-accent', 'sidebar-accent-foreground'], ['sidebar-primary', 'sidebar-primary-foreground'], ]) { assert.ok( contrast(tokens[background], tokens[foreground]) >= 4.5, selector + ': ' + background + '/' + foreground, ); } assert.ok(contrast(tokens.background, tokens.ring) >= 3); assert.ok(contrast(tokens.card, tokens.input) >= 3); } const appCss = await readFile('resources/css/app.css', 'utf8'); assert.match(appCss, /@media \(prefers-reduced-motion: reduce\)/); assert.match(appCss, /animation-duration: 0.01ms !important/); assert.match(appCss, /transition-duration: 0.01ms !important/); assert.match(appCss, /scroll-behavior: auto !important/); console.log('Appearance behavior checks passed.'); } finally { delete globalThis.window; delete globalThis.document; await server.close(); }