1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3function createStorage(overrides: Partial<Storage> = {}): Storage {
4 const store = new Map<string, string>();
5 return {
6 clear: () => store.clear(),
7 getItem: (key) => store.get(key) ?? null,
8 key: (index) => [...store.keys()][index] ?? null,
9 get length() {
10 return store.size;
11 },
12 removeItem: (key) => {
13 store.delete(key);
14 },
15 setItem: (key, value) => {
16 store.set(key, value);
17 },
18 ...overrides,
19 };
20}
21
22afterEach(() => {
23 vi.doUnmock("react");
24 vi.resetModules();
25 vi.restoreAllMocks();
26 vi.unstubAllGlobals();
27});
28
29describe("theme failure handling", () => {
30 it("preserves exact storage causes and operation context", async () => {
31 const readCause = new Error("storage read blocked");
32 const writeCause = new Error("storage quota exceeded");
33 vi.stubGlobal("window", {
34 localStorage: createStorage({
35 getItem: () => {
36 throw readCause;
37 },
38 setItem: () => {
39 throw writeCause;
40 },
41 }),
42 });
43
44 const { readThemePreference, ThemeStorageError, writeThemePreference } =
45 await import("./useTheme");
46
47 try {
48 readThemePreference();
49 expect.unreachable("expected the theme read to fail");
50 } catch (error) {
51 expect(error).toBeInstanceOf(ThemeStorageError);
52 expect(error).toMatchObject({
53 operation: "read",
54 storageKey: "t3code:theme",
55 cause: readCause,
56 });
57 }
58
59 try {
60 writeThemePreference("dark");
61 expect.unreachable("expected the theme write to fail");
62 } catch (error) {
63 expect(error).toBeInstanceOf(ThemeStorageError);
64 expect(error).toMatchObject({
65 operation: "write",
66 storageKey: "t3code:theme",
67 theme: "dark",
68 cause: writeCause,
69 });
70 }
71 });
72
73 it("reads the persisted T3 Chat theme preference", async () => {
74 vi.stubGlobal("window", {
75 localStorage: createStorage({
76 getItem: () => "t3-chat",
77 }),
78 });
79
80 const { readThemePreference } = await import("./useTheme");
81
82 expect(readThemePreference()).toBe("t3-chat");
83 });
84
85 it("falls back during initial theme application and logs only safe attributes", async () => {
86 const cause = new Error("private browsing storage failure");
87 const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
88 vi.stubGlobal("window", {
89 localStorage: createStorage({
90 getItem: () => {
91 throw cause;
92 },
93 }),
94 matchMedia: () => ({ matches: false }),
95 });
96 vi.stubGlobal("document", {
97 documentElement: {
98 classList: { toggle: vi.fn() },
99 },
100 });
101
102 await expect(import("./useTheme")).resolves.toBeDefined();
103
104 expect(errorLog).toHaveBeenCalledWith(
105 "Failed to read theme preference for t3code:theme.",
106 expect.objectContaining({
107 operation: "read",
108 storageKey: "t3code:theme",
109 errorTag: "ThemeStorageError",
110 }),
111 );
112 const attributes = errorLog.mock.calls[0]?.[1];
113 expect(attributes).not.toHaveProperty("cause");
114 expect(JSON.stringify(attributes)).not.toContain(cause.message);
115 });
116
117 it("retries a failed storage read only after a relevant storage event", async () => {
118 const cause = new Error("persistent storage failure");
119 const themeGetItem = vi.fn((): string | null => {
120 throw cause;
121 });
122 const getItem = vi.fn((key: string) => (key === "t3code:theme" ? themeGetItem() : null));
123 const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
124 let readSnapshot: (() => unknown) | undefined;
125 let subscribeToTheme: ((listener: () => void) => () => void) | undefined;
126 let storageHandler: ((event: StorageEvent) => void) | undefined;
127 vi.doMock("react", () => ({
128 useCallback: <A>(callback: A) => callback,
129 useEffect: () => undefined,
130 useSyncExternalStore: (
131 subscribe: (listener: () => void) => () => void,
132 getSnapshot: () => unknown,
133 ) => {
134 subscribeToTheme = subscribe;
135 readSnapshot = getSnapshot;
136 return getSnapshot();
137 },
138 }));
139 vi.stubGlobal("window", {
140 addEventListener: (type: string, listener: (event: StorageEvent) => void) => {
141 if (type === "storage") storageHandler = listener;
142 },
143 localStorage: createStorage({ getItem }),
144 matchMedia: () => ({
145 matches: false,
146 addEventListener: () => undefined,
147 removeEventListener: () => undefined,
148 }),
149 removeEventListener: () => undefined,
150 });
151
152 const { useTheme } = await import("./useTheme");
153 useTheme();
154 readSnapshot?.();
155 readSnapshot?.();
156
157 expect(themeGetItem).toHaveBeenCalledTimes(1);
158 expect(errorLog).toHaveBeenCalledTimes(1);
159
160 const unsubscribe = subscribeToTheme?.(() => undefined);
161 storageHandler?.({ key: "t3code:theme" } as StorageEvent);
162 readSnapshot?.();
163
164 expect(themeGetItem).toHaveBeenCalledTimes(2);
165 expect(errorLog).toHaveBeenCalledTimes(2);
166 unsubscribe?.();
167 });
168
169 it("preserves desktop sync causes and retries after a failed cosmetic sync", async () => {
170 const cause = new Error("desktop IPC unavailable");
171 const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
172 const setTheme = vi.fn().mockRejectedValue(cause);
173 vi.stubGlobal("window", { desktopBridge: { setTheme } });
174
175 const { DesktopThemeSyncError, syncDesktopTheme, syncDesktopThemePreference } =
176 await import("./useTheme");
177
178 const error = await syncDesktopThemePreference({ setTheme }, "dark").then(
179 () => undefined,
180 (failure: unknown) => failure,
181 );
182 expect(error).toBeInstanceOf(DesktopThemeSyncError);
183 expect(error).toMatchObject({ theme: "dark", cause });
184
185 setTheme.mockClear();
186 syncDesktopTheme("dark");
187 await Promise.resolve();
188 await Promise.resolve();
189 syncDesktopTheme("dark");
190 await Promise.resolve();
191 await Promise.resolve();
192
193 expect(setTheme).toHaveBeenCalledTimes(2);
194 expect(errorLog).toHaveBeenCalledWith(
195 "Failed to sync the dark theme to the desktop shell.",
196 expect.objectContaining({
197 theme: "dark",
198 errorTag: "DesktopThemeSyncError",
199 }),
200 );
201 for (const [, attributes] of errorLog.mock.calls) {
202 expect(attributes).not.toHaveProperty("cause");
203 expect(JSON.stringify(attributes)).not.toContain(cause.message);
204 }
205 });
206});