Skip to content

Commit 8cc3574

Browse files
committed
[dark-mode] Phase 3.4: localStorage persistence edge-case tests (FR-2.2)
Add 9 new persistence tests to theme-persistence.spec.ts covering edge cases from FR-2.2.1 through FR-2.2.4. All 82 tests passing. New tests: - Cross-page navigation: theme survives navigating away and back - localStorage.clear(): resets to default light mode - removeItem(key): resets to default light mode - Invalid value ('invalid-theme'): no crash, toggle still functional - Rapid toggles (7x): final state persisted correctly, survives reload - Storage format: value is plain string, not JSON object - No key leakage: only codex-docs-theme key in storage - CSS variables: --color-bg-main and --color-text-main correct after reload - Toggle icon: sun/moon visibility correct after reload
1 parent feea8a4 commit 8cc3574

2 files changed

Lines changed: 154 additions & 2 deletions

File tree

.github/specs/dark-mode/documentation/3-progress-report.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
| **3.1** | Visual testing, Playwright E2E suite (35 tests), structural CSS fixes, color palette redesign, bug fixes, NeDB migration | ✅ Done |
3131
| **3.2** | Accessibility testing (WCAG AA contrast, keyboard nav, focus visibility, ARIA) | ✅ Done |
3232
| **3.3** | Performance testing (< 100ms theme switch, no layout shifts, CSS architecture) | ✅ Done |
33-
| **3.4** | localStorage persistence testing | Not Started |
33+
| **3.4** | localStorage persistence testing (edge cases, cross-navigation, validation) | ✅ Done |
3434
| **3.5** | Browser compatibility testing | Not Started |
3535
| **4.1** | Code review & cleanup | Not Started |
3636
| **4.2** | Unit tests for ThemeManager | Not Started |
@@ -117,6 +117,26 @@ Fixes applied to both `[data-theme="dark"]` and `@media (prefers-color-scheme: d
117117

118118
---
119119

120+
## Phase 3.4 Completion Details — localStorage Persistence (FR-2.2)
121+
122+
### Playwright Persistence Test Suite (9 new tests added to theme-persistence.spec.ts)
123+
124+
| Test | Coverage |
125+
|------|----------|
126+
| Cross-page navigation persistence | Theme survives navigating away and back |
127+
| Clearing localStorage resets to light | `localStorage.clear()` → default light |
128+
| Removing only theme key resets | `removeItem()` → default light |
129+
| Invalid localStorage value handling | `'invalid-theme'` doesn't crash; toggle still works |
130+
| Rapid toggles persist final state | 7 toggles → final value persisted and survives reload |
131+
| Storage format validation | Value is plain string, not JSON object |
132+
| No key leakage | Only `codex-docs-theme` key exists |
133+
| CSS variables match after reload | `--color-bg-main`/`--color-text-main` correct post-reload |
134+
| Toggle icon state after reload | Sun/moon icon visibility correct post-reload |
135+
136+
**Total test suite:** 82 tests (73 existing + 9 new) — all passing.
137+
138+
---
139+
120140
## Architecture Summary
121141

122142
- **Approach:** CSS custom properties + `[data-theme="dark"]` attribute on `<html>`

e2e/dark-mode/theme-persistence.spec.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect, STORAGE_KEY, getThemeAttribute, goToThemedPage, selectors } from '../fixtures/setup';
1+
import { test, expect, STORAGE_KEY, getThemeAttribute, goToThemedPage, selectors, getCSSVariable, themeColors } from '../fixtures/setup';
22

33
test.describe('Theme Persistence', () => {
44
test('defaults to light mode when no saved preference', async ({ page }) => {
@@ -57,3 +57,135 @@ test.describe('Theme Persistence', () => {
5757
expect(allKeys).toContain('codex-docs-theme');
5858
});
5959
});
60+
61+
test.describe('Theme Persistence — Edge Cases (FR-2.2)', () => {
62+
test('theme persists across page navigation', async ({ page }) => {
63+
await goToThemedPage(page);
64+
await page.click(selectors.themeToggleButton);
65+
expect(await getThemeAttribute(page)).toBe('dark');
66+
67+
// Navigate to a different page
68+
await page.goto('/');
69+
await page.goto('/auth');
70+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
71+
72+
expect(await getThemeAttribute(page)).toBe('dark');
73+
});
74+
75+
test('clearing localStorage resets to default light mode', async ({ page }) => {
76+
await goToThemedPage(page);
77+
await page.click(selectors.themeToggleButton);
78+
expect(await getThemeAttribute(page)).toBe('dark');
79+
80+
// Clear storage and reload
81+
await page.evaluate(() => localStorage.clear());
82+
await page.reload();
83+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
84+
85+
expect(await getThemeAttribute(page)).toBe('light');
86+
});
87+
88+
test('removing only the theme key resets to default', async ({ page }) => {
89+
await goToThemedPage(page);
90+
await page.click(selectors.themeToggleButton);
91+
92+
await page.evaluate((key) => localStorage.removeItem(key), STORAGE_KEY);
93+
await page.reload();
94+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
95+
96+
expect(await getThemeAttribute(page)).toBe('light');
97+
});
98+
99+
test('invalid localStorage value does not crash theme initialization', async ({ page }) => {
100+
await page.goto('/auth');
101+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
102+
103+
// Set an invalid theme value
104+
await page.evaluate((key) => localStorage.setItem(key, 'invalid-theme'), STORAGE_KEY);
105+
await page.reload();
106+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
107+
108+
// Page should still work — toggle should still function
109+
await page.click(selectors.themeToggleButton);
110+
const stored = await page.evaluate((key) => localStorage.getItem(key), STORAGE_KEY);
111+
expect(['light', 'dark']).toContain(stored);
112+
});
113+
114+
test('rapid toggles persist the final state', async ({ page }) => {
115+
await goToThemedPage(page);
116+
117+
// Toggle 7 times — odd count means dark
118+
for (let i = 0; i < 7; i++) {
119+
await page.click(selectors.themeToggleButton);
120+
}
121+
122+
const finalTheme = await getThemeAttribute(page);
123+
const stored = await page.evaluate((key) => localStorage.getItem(key), STORAGE_KEY);
124+
expect(stored).toBe(finalTheme);
125+
126+
// Reload and verify persistence
127+
await page.reload();
128+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
129+
expect(await getThemeAttribute(page)).toBe(finalTheme);
130+
});
131+
132+
test('localStorage stores only valid string values (not objects)', async ({ page }) => {
133+
await goToThemedPage(page);
134+
await page.click(selectors.themeToggleButton);
135+
136+
const stored = await page.evaluate((key) => {
137+
const val = localStorage.getItem(key);
138+
return {
139+
value: val,
140+
type: typeof val,
141+
isJSON: (() => { try { JSON.parse(val!); return typeof JSON.parse(val!) === 'object'; } catch { return false; } })(),
142+
};
143+
}, STORAGE_KEY);
144+
145+
expect(stored.type).toBe('string');
146+
expect(stored.isJSON).toBe(false);
147+
expect(['light', 'dark']).toContain(stored.value);
148+
});
149+
150+
test('theme preference does not leak into other storage keys', async ({ page }) => {
151+
await goToThemedPage(page);
152+
await page.click(selectors.themeToggleButton);
153+
154+
const themeKeys = await page.evaluate(() => {
155+
return Object.keys(localStorage).filter(k => k.includes('theme') || k.includes('dark') || k.includes('mode'));
156+
});
157+
158+
// Only our key should be theme-related
159+
expect(themeKeys).toEqual(['codex-docs-theme']);
160+
});
161+
162+
test('CSS variables match persisted theme after reload', async ({ page }) => {
163+
await goToThemedPage(page);
164+
await page.click(selectors.themeToggleButton);
165+
expect(await getThemeAttribute(page)).toBe('dark');
166+
167+
await page.reload();
168+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
169+
170+
// Verify CSS variables are dark mode values, not just the attribute
171+
const bgMain = await getCSSVariable(page, '--color-bg-main');
172+
const textMain = await getCSSVariable(page, '--color-text-main');
173+
expect(bgMain).toBe(themeColors.dark['--color-bg-main']);
174+
expect(textMain).toBe(themeColors.dark['--color-text-main']);
175+
});
176+
177+
test('toggle icon state matches persisted theme after reload', async ({ page }) => {
178+
await goToThemedPage(page);
179+
await page.click(selectors.themeToggleButton);
180+
expect(await getThemeAttribute(page)).toBe('dark');
181+
182+
await page.reload();
183+
await page.waitForSelector(selectors.themeToggleButton, { timeout: 10000 });
184+
185+
// In dark mode: sun icon hidden, moon icon visible
186+
const sunIcon = page.locator(selectors.sunIcon);
187+
const moonIcon = page.locator(selectors.moonIcon);
188+
await expect(sunIcon).toHaveCSS('display', 'none');
189+
await expect(moonIcon).toHaveCSS('display', 'block');
190+
});
191+
});

0 commit comments

Comments
 (0)