import { test, expect, type Page } from "@playwright/test"; import { nextBase, type ThemeBase } from "../../common/components/themeConfig"; import { baseLabel, themeCycle, themeToggle } from "./helpers"; // THE HOMEPAGE'S THEME CONTROL is one toggle that cycles the base // (ThemeToggle, `variant="bare"`), the last key of the header's group; there is // no options dialog and no accent control: the accent is the homepage's own. // The expected cycle is derived from THEME_BASES / nextBase, so // removing a base there needs no change here. const htmlState = (page: Page) => page.evaluate(() => ({ base: document.documentElement.getAttribute("data-base"), accent: document.documentElement.getAttribute("data-accent"), ready: document.documentElement.getAttribute("data-theme-ready"), })); for (const width of [320, 360, 390, 768, 1280]) { test(`${width} px: the toggle is in the header, a 36 px key, and there is no other theme control`, async ({ page, }) => { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); const toggle = themeToggle(page); await expect(toggle).toBeInViewport({ ratio: 1 }); const box = (await toggle.boundingBox())!; expect([box.width, box.height]).toEqual([36, 36]); await expect(toggle).toHaveText(""); await expect(page.getByRole("button", { name: "Options" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Choose theme" })).toHaveCount(0); await expect(page.getByRole("dialog")).toHaveCount(0); }); } test.describe("under a coarse pointer", () => { test.use({ hasTouch: true }); test("the toggle is a 44 px key", async ({ page }) => { await page.setViewportSize({ width: 360, height: 800 }); await page.goto("/"); const box = (await themeToggle(page).boundingBox())!; expect([box.width, box.height]).toEqual([44, 44]); }); }); test("each click moves to the next base: the page, the icon and the name follow, round the whole cycle", async ({ page, }) => { // The OS says light, so "system" resolves to light. await page.emulateMedia({ colorScheme: "light" }); await page.goto("/"); const toggle = themeToggle(page); const start = (await toggle.getAttribute("data-theme-base")) as ThemeBase; expect(start).toBe("dark"); // the homepage's own default const cycle = themeCycle(start); let current = start; for (let i = 0; i < cycle.length; i++) { const next = nextBase(current); await expect(toggle).toHaveAccessibleName(`Switch to ${baseLabel(next)}`); const icon = await toggle.locator("svg").getAttribute("class"); await expect(async () => { if ((await toggle.getAttribute("data-theme-base")) === current) await toggle.click(); expect(await toggle.getAttribute("data-theme-base")).toBe(next); }).toPass({ timeout: 10_000 }); expect(await toggle.locator("svg").getAttribute("class"), `${current} → ${next}: the icon`).not.toBe(icon); const resolved = next === "system" ? "light" : next; await expect.poll(async () => (await htmlState(page)).base).toBe(resolved); current = next; } expect(current, "the cycle comes back to its start").toBe(start); }); test("the toggle's focus: the ring, and in forced colours the browser's own outline, none at rest", async ({ page, }) => { await page.goto("/"); const toggle = themeToggle(page); const tabTo = async () => { for (let i = 0; i < 25; i++) { await page.keyboard.press("Tab"); if (await toggle.evaluate((el) => el === document.activeElement)) return; } throw new Error("Tab never reached the toggle"); }; await tabTo(); const normal = await toggle.evaluate((el) => { const s = getComputedStyle(el); return { outline: s.outlineStyle, shadow: s.boxShadow }; }); expect(normal.outline).toBe("none"); expect(normal.shadow).toMatch(/0px 0px 0px 2px/); await page.emulateMedia({ forcedColors: "active" }); await page.goto("/"); expect(await toggle.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe("none"); await tabTo(); const forced = await toggle.evaluate((el) => { const s = getComputedStyle(el); return { style: s.outlineStyle, width: parseFloat(s.outlineWidth) }; }); expect(forced.style).not.toBe("none"); expect(forced.width).toBeGreaterThan(0); }); // With no accent control, a stored accent (set on this origin by an earlier // build that had one) must not tint the page: the pre-paint script // and the provider both ignore it, and it stays in storage untouched. Every // value `data-accent` ever holds is recorded by a MutationObserver installed // before the page's first script, so a flash between two samples cannot pass. test("a stored accent is ignored: the homepage keeps its own, with no flash, and the stored value stays", async ({ page, }) => { await page.addInitScript(() => { try { localStorage.setItem("ytdlp-tb:accent", "violet"); } catch {} const held: (string | null)[] = []; (window as unknown as { __accents: (string | null)[] }).__accents = 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-accent"], attributeOldValue: true, }); }); await page.goto("/", { waitUntil: "commit" }); await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1"); await page.waitForLoadState("load"); await expect.poll(async () => (await htmlState(page)).accent).toBe("signal"); await page.waitForTimeout(300); const held = await page.evaluate(() => [ ...(window as unknown as { __accents: (string | null)[] }).__accents, document.documentElement.getAttribute("data-accent"), ]); // Every value it held before each change, and the one it holds now. expect(held.filter((v) => v !== null && v !== "signal"), JSON.stringify(held)).toEqual([]); expect(held.at(-1)).toBe("signal"); expect(await page.evaluate(() => localStorage.getItem("ytdlp-tb:accent"))).toBe("violet"); });