Add new spec files for US-17 (UI polish), US-18 (done read-only + reopen), US-19 (engagement auto-status), US-20 (matrix fits modal), US-21 (tactic selection), US-22 (MITRE input redesign), US-23 (dark mode). Adapt sprint 2/3 specs for sprint 4 UI renames: matrix icon button replaces text buttons, inline search replaces Quick Search, Save replaces Save Red Team, New replaces New Engagement, topbar uses bg-slab tokens, Apply N item(s) replaces Apply N technique(s), done→review_required transition now valid (Reopen flow). Mark AC-21.6 Apply-from-modal as test.fail: known defect where /api/mitre/matrix returns slug tactic IDs but PATCH /simulations/:id expects TA-format IDs. Final result: 156 passed, 0 failed (1 expected failure via test.fail). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
176 lines
6.3 KiB
TypeScript
176 lines
6.3 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|