1
24import type { Page } from "@playwright/test";
25import { expect, test } from "../fixtures";
26
27
28interface ThemeSignal {
29 dark: boolean;
30 bodyBg: string;
31 stored: string;
32}
33
34function readTheme(page: Page): Promise<ThemeSignal> {
35 return page.evaluate(() => ({
36 dark: document.documentElement.classList.contains("dark"),
37 bodyBg: getComputedStyle(document.body).backgroundColor,
38 stored: localStorage.getItem("factory-theme") ?? "",
39 }));
40}
41
42function isTransparent(bg: string): boolean {
43
44 return /rgba?\([^)]*,\s*(0|0\.\d+)\s*\)$/.test(bg) || bg === "transparent";
45}
46
47async function gotoAppearance(page: Page, workspaceId: string): Promise<void> {
48 await page.goto(`/w/${workspaceId}/settings/appearance`);
49
50 await expect(
51 page.getByRole("button", { name: /^dark$/i }),
52 ).toBeVisible();
53}
54
55test("theme: dark toggles .dark, paints an opaque distinct bg, and persists", async ({
56 authPage,
57 freshWorkspace,
58}) => {
59 const ws = await freshWorkspace("E2E Theme");
60 await gotoAppearance(authPage, ws.id);
61
62 const light = await readTheme(authPage);
63
64 expect(light.dark).toBe(false);
65 expect(isTransparent(light.bodyBg)).toBe(false);
66
67
68 await authPage.getByRole("button", { name: /^dark$/i }).click();
69 await expect.poll(async () => (await readTheme(authPage)).dark).toBe(true);
70 const dark = await readTheme(authPage);
71 expect(dark.dark).toBe(true);
72 expect(dark.stored).toBe("dark");
73
74
75 expect(isTransparent(dark.bodyBg)).toBe(false);
76 expect(dark.bodyBg).not.toBe(light.bodyBg);
77
78
79 await authPage.reload();
80 await expect.poll(async () => (await readTheme(authPage)).dark).toBe(true);
81 expect((await readTheme(authPage)).stored).toBe("dark");
82
83
84
85 await authPage.goto(`/w/${ws.id}/settings`);
86 await expect
87 .poll(async () =>
88 authPage.evaluate(() => document.documentElement.classList.contains("dark")),
89 )
90 .toBe(true);
91
92
93 await authPage.goto(`/w/${ws.id}/settings/appearance`);
94 await authPage.getByRole("button", { name: /^light$/i }).click();
95 await expect.poll(async () => (await readTheme(authPage)).dark).toBe(false);
96 const backToLight = await readTheme(authPage);
97 expect(backToLight.stored).toBe("light");
98 expect(isTransparent(backToLight.bodyBg)).toBe(false);
99 expect(backToLight.bodyBg).toBe(light.bodyBg);
100});
101
102test("theme: system follows the OS preference via .dark sync", async ({
103 authPage,
104 freshWorkspace,
105}) => {
106 const ws = await freshWorkspace("E2E Theme System");
107 await gotoAppearance(authPage, ws.id);
108
109
110 await authPage.evaluate(() => localStorage.removeItem("factory-theme"));
111 await authPage.reload();
112 await gotoAppearance(authPage, ws.id);
113 await authPage.getByRole("button", { name: /^system$/i }).click();
114
115
116 await authPage.emulateMedia({ colorScheme: "dark" });
117 await expect
118 .poll(async () =>
119 authPage.evaluate(() => document.documentElement.classList.contains("dark")),
120 )
121 .toBe(true);
122
123
124
125 await authPage.emulateMedia({ colorScheme: "light" });
126 await expect
127 .poll(async () =>
128 authPage.evaluate(() => document.documentElement.classList.contains("dark")),
129 )
130 .toBe(false);
131});
132