import { test, expect, type Locator, type Page } from "@playwright/test"; import { RETIRED_BASE } from "../../common/components/themeConfig"; // A published site opens on the reader's SYSTEM base (tokens.css resolves it // to the light or dark block) and follows the OS live. The base toggle cycles // system → light → dark → system; every choice persists across a // reload and is applied by the pre-paint ThemeScript before hydration — the // reload waits on `data-theme-ready`, which the script sets last. const BASE_KEY = "ytdlp-tb:base"; async function state(page: Page) { return page.evaluate((key) => { const d = document.documentElement; return { base: d.getAttribute("data-base"), dark: d.classList.contains("dark"), stored: localStorage.getItem(key), background: getComputedStyle(d).getPropertyValue("--background").trim(), }; }, BASE_KEY); } // A click before hydration is lost (the button is server-rendered inert), so // click until the stored base is the one wanted. A click React did take is // committed synchronously (a discrete event), so the check right after it // never races a taken click into a second one. async function toggleTo(page: Page, toggle: Locator, want: string | null) { await expect(async () => { if ((await state(page)).stored !== want) await toggle.click(); expect((await state(page)).stored).toBe(want); }).toPass({ timeout: 10_000 }); } async function reloadOnCommit(page: Page) { await page.reload({ waitUntil: "commit" }); await page.waitForFunction( () => document.documentElement?.dataset.themeReady === "1", ); } test("a site opens on the system base and follows the OS live", async ({ page, }) => { await page.emulateMedia({ colorScheme: "light" }); await page.goto("/"); await expect .poll(() => state(page)) .toEqual({ base: "light", dark: false, stored: null, background: "#f3f6f7" }); await expect(page.getByRole("button", { name: "Switch to light" })).toBeVisible(); // The OS flips while the page is open: "system" follows without a reload. await page.emulateMedia({ colorScheme: "dark" }); await expect .poll(() => state(page)) .toEqual({ base: "dark", dark: true, stored: null, background: "#0c0a08" }); await page.emulateMedia({ colorScheme: "light" }); await expect.poll(async () => (await state(page)).base).toBe("light"); }); test("the toggle cycles the three bases; each persists across a reload with no flash", async ({ page, }) => { await page.emulateMedia({ colorScheme: "light" }); await page.goto("/"); const toggle = page.getByRole("button", { name: /switch to/i }); await expect(toggle).toBeVisible(); const steps = [ { stored: "light", base: "light", dark: false, background: "#f3f6f7", next: "Switch to dark" }, { stored: "dark", base: "dark", dark: true, background: "#0c0a08", next: "Switch to system" }, // Back to system: stored as "system", resolved against the (light) OS. { stored: "system", base: "light", dark: false, background: "#f3f6f7", next: "Switch to light" }, ]; for (const step of steps) { await toggleTo(page, toggle, step.stored); const s = await state(page); expect(s.base, `after ${step.stored}`).toBe(step.base); expect(s.dark, `after ${step.stored}`).toBe(step.dark); expect(s.background, `after ${step.stored}`).toBe(step.background); await expect(page.getByRole("button", { name: step.next })).toBeVisible(); // The pre-paint script re-applies it on the very first commit, before // React hydrates. await reloadOnCommit(page); const onCommit = await state(page); expect(onCommit.stored, `reload after ${step.stored}`).toBe(step.stored); expect(onCommit.base, `reload after ${step.stored}`).toBe(step.base); expect(onCommit.dark, `reload after ${step.stored}`).toBe(step.dark); } }); test("the retired theme/mode keys migrate once, before paint", async ({ page, }) => { await page.emulateMedia({ colorScheme: "dark" }); await page.goto("/"); await page.evaluate(() => { localStorage.removeItem("ytdlp-tb:base"); localStorage.setItem("ytdlp-tb:theme", "archive"); localStorage.setItem("ytdlp-tb:mode", "light"); }); await reloadOnCommit(page); const s = await page.evaluate(() => ({ base: document.documentElement.getAttribute("data-base"), stored: localStorage.getItem("ytdlp-tb:base"), theme: localStorage.getItem("ytdlp-tb:theme"), mode: localStorage.getItem("ytdlp-tb:mode"), })); // archive + light was the paper look, the retired third ground: it becomes // light, and the old keys go. expect(s).toEqual({ base: "light", stored: "light", theme: null, mode: null }); }); // The retired third ground: a reader who chose it gets Light, before first // paint, with no other ground on the way, and the stored value becomes // "light" once. Every value `data-base` holds is recorded by a // MutationObserver installed before the page's first script. test("a stored retired base renders Light, with no other ground on the way, and is rewritten", async ({ page, }) => { await page.emulateMedia({ colorScheme: "dark" }); await page.addInitScript( ([key, retired]) => { try { localStorage.setItem(key, retired); } catch {} const held: (string | null)[] = []; (window as unknown as { __bases: (string | null)[] }).__bases = held; new MutationObserver((records) => { for (const r of records) if (r.target === document.documentElement) held.push(r.oldValue); }).observe(document, { subtree: true, attributes: true, attributeFilter: ["data-base"], attributeOldValue: true, }); }, [BASE_KEY, RETIRED_BASE], ); await page.goto("/", { waitUntil: "commit" }); await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1"); expect(await page.evaluate(() => document.documentElement.getAttribute("data-base"))).toBe("light"); await page.waitForLoadState("load"); await page.waitForTimeout(300); const held = await page.evaluate(() => [ ...(window as unknown as { __bases: (string | null)[] }).__bases, document.documentElement.getAttribute("data-base"), ]); // None (a site's server markup has no base: it is the reader's system), then // light — never the retired ground, never dark from the OS. expect(held.filter((v) => v !== null && v !== "light"), JSON.stringify(held)).toEqual([]); expect(held.at(-1)).toBe("light"); expect(await state(page)).toMatchObject({ base: "light", dark: false, stored: "light" }); });