/** * US-23 — Dark mode. * Covers AC-23.1 (toggle in topbar), AC-23.2 (3-state cycle), AC-23.3 (localStorage persistence). * AC-23.4/5/6 (Tailwind tokens, component audit, screenshots) are frontend-builder scope. */ import { test, expect } from '@playwright/test'; import { adminToken, deleteUserByUsername, ensureUser, login, } from '../fixtures/api'; import { seedTokenInStorage } from '../fixtures/auth'; const REDTEAM_USER = 'us23-redteam'; const PASS = 'us23-pass-strong'; test.describe('US-23 — dark mode', () => { let redteamToken: string; test.beforeAll(async () => { await ensureUser(REDTEAM_USER, PASS, 'redteam'); redteamToken = (await login(REDTEAM_USER, PASS)).token; }); test.afterAll(async () => { try { const tok = await adminToken(); await deleteUserByUsername(tok, REDTEAM_USER); } catch { /* noop */ } }); test('AC-23.1 — theme toggle button is visible in the topbar', async ({ page, context, }) => { await seedTokenInStorage(context, redteamToken); await page.goto('/engagements'); // The theme toggle has aria-label containing "Theme:" and the current mode const themeBtn = page.getByRole('button', { name: /theme:/i }); await expect(themeBtn).toBeVisible(); // Shows current theme label (Light, Dark, or System) const label = await themeBtn.textContent(); expect(label).toMatch(/light|dark|system/i); }); test('AC-23.2 — toggle cycles through 3 states: system → light → dark → system', async ({ page, context, }) => { await seedTokenInStorage(context, redteamToken); await page.goto('/engagements'); // Clear any stored theme via evaluate (not addInitScript — that would fire on every load) await page.evaluate(() => localStorage.removeItem('mimic-theme')); await page.reload(); await page.waitForLoadState('networkidle'); const themeBtn = page.getByRole('button', { name: /theme:/i }); await expect(themeBtn).toBeVisible(); // Collect 4 states to confirm a full cycle const states: string[] = []; for (let i = 0; i < 4; i++) { const label = await themeBtn.textContent(); states.push(label?.trim().toLowerCase() ?? ''); await themeBtn.click(); await page.waitForTimeout(100); } // Must contain all 3 modes within the cycle expect(states.some(s => s.includes('system'))).toBe(true); expect(states.some(s => s.includes('light'))).toBe(true); expect(states.some(s => s.includes('dark'))).toBe(true); // 4th state must equal 1st (full cycle completed) expect(states[3]).toBe(states[0]); }); test('AC-23.3 — theme persists in localStorage under "mimic-theme"', async ({ page, context, }) => { await seedTokenInStorage(context, redteamToken); await page.goto('/engagements'); // Clear any stored theme via evaluate (NOT addInitScript — that runs on reload too) await page.evaluate(() => localStorage.removeItem('mimic-theme')); await page.reload(); await page.waitForLoadState('networkidle'); const themeBtn = page.getByRole('button', { name: /theme:/i }); await expect(themeBtn).toBeVisible(); // Click until we reach "dark" for (let i = 0; i < 5; i++) { const label = await themeBtn.textContent(); if (label?.toLowerCase().includes('dark')) break; await themeBtn.click(); await page.waitForTimeout(100); } // Read localStorage — must be 'dark' const stored = await page.evaluate(() => localStorage.getItem('mimic-theme')); expect(stored).toBe('dark'); // Reload page — should restore dark mode (localStorage persists across reload) await page.reload(); await page.waitForLoadState('networkidle'); const themeAfterReload = page.getByRole('button', { name: /theme:/i }); await expect(themeAfterReload).toBeVisible(); const labelAfterReload = await themeAfterReload.textContent(); expect(labelAfterReload?.toLowerCase()).toContain('dark'); // html element should have class "dark" const hasDarkClass = await page.evaluate(() => document.documentElement.classList.contains('dark')); expect(hasDarkClass).toBe(true); }); test('AC-23.3 — default is "system" when no localStorage value', async ({ page, context, }) => { await seedTokenInStorage(context, redteamToken); await page.goto('/engagements'); // Clear theme via evaluate then reload so page renders with no stored theme await page.evaluate(() => localStorage.removeItem('mimic-theme')); await page.reload(); await page.waitForLoadState('networkidle'); const stored = await page.evaluate(() => localStorage.getItem('mimic-theme')); // Default state: localStorage not yet set (null) OR set to 'system' expect(stored === null || stored === 'system').toBe(true); const themeBtn = page.getByRole('button', { name: /theme:/i }); const label = await themeBtn.textContent(); // Initial label should be System (or light if system resolves to light) expect(label?.toLowerCase()).toMatch(/system|light|dark/); }); test('AC-23.1 — dark mode: html has class "dark" when dark selected', async ({ page, context, }) => { await seedTokenInStorage(context, redteamToken); await page.goto('/engagements'); // Set dark theme via evaluate then reload so React reads it on mount await page.evaluate(() => localStorage.setItem('mimic-theme', 'dark')); await page.reload(); await page.waitForLoadState('networkidle'); // Wait for React to apply the dark class via useEffect await page.waitForFunction(() => document.documentElement.classList.contains('dark'), { timeout: 3_000 }); const hasDarkClass = await page.evaluate(() => document.documentElement.classList.contains('dark'), ); expect(hasDarkClass).toBe(true); }); test('AC-23.1 — light mode: html does NOT have class "dark" when light selected', async ({ page, context, }) => { await seedTokenInStorage(context, redteamToken); await page.goto('/engagements'); await page.evaluate(() => localStorage.setItem('mimic-theme', 'light')); await page.reload(); await page.waitForLoadState('networkidle'); const hasDarkClass = await page.evaluate(() => document.documentElement.classList.contains('dark'), ); expect(hasDarkClass).toBe(false); }); });