diff --git a/package.json b/package.json index 8b848c5..a9ca078 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "start": "node_modules/node/bin/node node_modules/next/dist/bin/next start", "lint": "node_modules/node/bin/node node_modules/eslint/bin/eslint.js", "typecheck": "node_modules/node/bin/node node_modules/next/dist/bin/next typegen && node_modules/node/bin/node node_modules/typescript/bin/tsc --noEmit", - "test": "npm run test:auth-security && npm run test:feedback && npm run test:wholesale && npm run test:channel-submissions && npm run test:api-transit && npm run test:product-offer-pagination && npm run test:catalog && npm run test:api-models && npm run test:official-parser && npm run test:buyer-fee", + "test": "npm run test:theme-init && npm run test:auth-security && npm run test:feedback && npm run test:wholesale && npm run test:channel-submissions && npm run test:api-transit && npm run test:product-offer-pagination && npm run test:catalog && npm run test:api-models && npm run test:official-parser && npm run test:buyer-fee", + "test:theme-init": "node scripts/run-ts-test.mjs scripts/test-theme-init.ts", "collect:prices": "node scripts/collect-prices.mjs", "collect:shop-vip": "node scripts/collect-prices.mjs --all --kind shopApi --shop-scheduler --shop-scheduler-group vip_15m --post", "rebalance:source-shards": "node scripts/rebalance-source-shards.mjs", diff --git a/scripts/test-theme-init.ts b/scripts/test-theme-init.ts new file mode 100644 index 0000000..b0868d0 --- /dev/null +++ b/scripts/test-theme-init.ts @@ -0,0 +1,167 @@ +import { runInNewContext } from "node:vm"; +import { THEME_INIT_SCRIPT, THEME_STORAGE_KEY } from "../src/lib/theme-init.js"; + +type ThemeMode = "light" | "dark"; + +type ThemeCase = { + name: string; + pathname?: string; + stored?: string | null; + prefersDark?: boolean; + hasMatchMedia?: boolean; + storageError?: boolean; + mediaError?: boolean; + expected: ThemeMode; + expectedStorageReads?: number; + expectedMediaReads?: number; +}; + +const cases: ThemeCase[] = [ + { + name: "stored dark overrides a light system preference", + stored: "dark", + prefersDark: false, + expected: "dark", + }, + { + name: "stored light overrides a dark system preference", + stored: "light", + prefersDark: true, + expected: "light", + }, + { + name: "missing storage follows a dark system preference", + stored: null, + prefersDark: true, + expected: "dark", + }, + { + name: "missing storage follows a light system preference", + stored: null, + prefersDark: false, + expected: "light", + }, + { + name: "an unknown stored value preserves the light fallback", + stored: "system", + prefersDark: true, + expected: "light", + }, + { + name: "missing matchMedia preserves the light fallback", + stored: null, + hasMatchMedia: false, + expected: "light", + expectedMediaReads: 0, + }, + { + name: "a storage error falls back to light", + storageError: true, + expected: "light", + expectedMediaReads: 0, + }, + { + name: "a media query error falls back to light", + stored: null, + mediaError: true, + expected: "light", + }, + { + name: "the admin root stays light", + pathname: "/admin", + stored: "dark", + prefersDark: true, + expected: "light", + expectedStorageReads: 0, + expectedMediaReads: 0, + }, + { + name: "admin child routes stay light", + pathname: "/admin/api-transit", + stored: "dark", + prefersDark: true, + expected: "light", + expectedStorageReads: 0, + expectedMediaReads: 0, + }, + { + name: "the existing admin prefix behavior remains compatible", + pathname: "/administrator", + stored: "dark", + prefersDark: true, + expected: "light", + expectedStorageReads: 0, + expectedMediaReads: 0, + }, +]; + +for (const testCase of cases) { + const result = runThemeInit(testCase); + assertEqual(result.theme, testCase.expected, `${testCase.name}: data-theme`); + assertEqual(result.colorScheme, testCase.expected, `${testCase.name}: colorScheme`); + + if (testCase.expectedStorageReads !== undefined) { + assertEqual(result.storageReads, testCase.expectedStorageReads, `${testCase.name}: storage reads`); + } + if (testCase.expectedMediaReads !== undefined) { + assertEqual(result.mediaReads, testCase.expectedMediaReads, `${testCase.name}: media query reads`); + } +} + +assertEqual( + THEME_INIT_SCRIPT.toLowerCase().includes(" = {}; + const style: Record = {}; + let storageReads = 0; + let mediaReads = 0; + + const windowObject: { + location: { pathname: string }; + localStorage: { getItem: (key: string) => string | null }; + matchMedia?: (query: string) => { matches: boolean }; + } = { + location: { pathname: testCase.pathname ?? "/" }, + localStorage: { + getItem(key) { + storageReads += 1; + assertEqual(key, THEME_STORAGE_KEY, `${testCase.name}: storage key`); + if (testCase.storageError) throw new Error("storage unavailable"); + return testCase.stored ?? null; + }, + }, + }; + + if (testCase.hasMatchMedia !== false) { + windowObject.matchMedia = (query) => { + mediaReads += 1; + assertEqual(query, "(prefers-color-scheme: dark)", `${testCase.name}: media query`); + if (testCase.mediaError) throw new Error("media query unavailable"); + return { matches: testCase.prefersDark ?? false }; + }; + } + + runInNewContext(THEME_INIT_SCRIPT, { + document: { documentElement: { dataset, style } }, + window: windowObject, + }); + + return { + theme: dataset.theme, + colorScheme: style.colorScheme, + storageReads, + mediaReads, + }; +} + +function assertEqual(actual: T, expected: T, message: string) { + if (actual !== expected) { + throw new Error(`${message}. Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}.`); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 70b722d..f5e5a50 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import Script from "next/script"; import { Suspense } from "react"; import { GlobalSponsorPlacements } from "@/components/GlobalSponsorPlacements"; import { GlobalSiteFooter } from "@/components/GlobalSiteFooter"; @@ -8,30 +7,9 @@ import { QQGroupAutoPrompt } from "@/components/QQGroupAutoPrompt"; import { SiteNoticePrompt } from "@/components/SiteNoticePrompt"; import { SupportNudgePrompt } from "@/components/SupportNudgePrompt"; import { UmamiAnalytics } from "@/components/UmamiAnalytics"; +import { THEME_INIT_SCRIPT } from "@/lib/theme-init"; import "./globals.css"; -const themeInitScript = ` -(function() { - try { - var root = document.documentElement; - var isAdmin = window.location.pathname.indexOf('/admin') === 0; - if (isAdmin) { - root.dataset.theme = 'light'; - root.style.colorScheme = 'light'; - return; - } - var stored = window.localStorage.getItem('priceai-theme'); - var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - var theme = stored === 'dark' || (!stored && prefersDark) ? 'dark' : 'light'; - root.dataset.theme = theme; - root.style.colorScheme = theme; - } catch (error) { - document.documentElement.dataset.theme = 'light'; - document.documentElement.style.colorScheme = 'light'; - } -})(); -`; - export const metadata: Metadata = { metadataBase: new URL("https://priceai.cc"), title: { @@ -72,8 +50,10 @@ export default function RootLayout({ }>) { return ( + +