/** * US-5 — UI respects DESIGN.md tokens, layout doesn't break at 1280×720, * and loading/error/empty states are wired. * * Pixel-perfect fidelity is NOT enforced — instead we assert that the * tokenised classes from tailwind.config.ts are present and that the canvas * has no horizontal overflow at the design viewport. */ import { test, expect } from '@playwright/test'; import { adminToken, deleteAllEngagements, deleteUserByUsername, ensureUser, login, } from '../fixtures/api'; import { seedTokenInStorage } from '../fixtures/auth'; const REDTEAM_USER = 'us5-redteam'; const PASS = 'us5-passw0rd'; test.describe('US-5 — DESIGN.md fidelity, responsive, states', () => { 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 deleteAllEngagements(tok); await deleteUserByUsername(tok, REDTEAM_USER); } catch { /* noop */ } }); test('AC-5.1 — DESIGN.md tokens applied (Inter font, brand palette, named utilities)', async ({ page, context, }) => { await seedTokenInStorage(context, redteamToken); await page.goto('/engagements'); // Body font must resolve to a stack including "Inter". const fontFamily = await page.evaluate( () => window.getComputedStyle(document.body).fontFamily, ); expect(fontFamily.toLowerCase()).toMatch(/inter/); // Tailwind-compiled DESIGN palette: the primary chevron at the top-left of // the nav uses `bg-primary` → rendered as rgb(2, 74, 216). const chevronBg = await page .locator('header a[aria-label="Mimic home"] span[aria-hidden]') .first() .evaluate((el) => window.getComputedStyle(el).backgroundColor); expect(chevronBg.replace(/\s/g, '')).toBe('rgb(2,74,216)'); // Topbar utility-strip is the ink slab (`bg-ink` → rgb(26, 26, 26)). const utilityBg = await page .locator('div.bg-ink.text-ink-on') .first() .evaluate((el) => window.getComputedStyle(el).backgroundColor); expect(utilityBg.replace(/\s/g, '')).toBe('rgb(26,26,26)'); // Spot-check a few semantic class names live in the DOM (proves tokens are // wired through tailwind.config.ts and not ad-hoc hex values). await expect(page.locator('.btn-primary, .card-product, .max-w-page').first()).toBeVisible(); }); test('AC-5.2 — no horizontal overflow at 1280×720 across key pages', async ({ page, context, }) => { await seedTokenInStorage(context, redteamToken); await page.setViewportSize({ width: 1280, height: 720 }); const routes = ['/engagements', '/engagements/new']; for (const route of routes) { await page.goto(route); const overflow = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, })); expect( overflow.scrollWidth, `${route} should not horizontally overflow at 1280px`, ).toBeLessThanOrEqual(overflow.clientWidth + 1); } }); test('AC-5.3a — empty state renders when engagements list is empty', async ({ page, context, }) => { await seedTokenInStorage(context, redteamToken); const tok = await adminToken(); await deleteAllEngagements(tok); await page.goto('/engagements'); await expect(page.getByRole('heading', { name: /no engagements yet/i })).toBeVisible(); }); test('AC-5.3b — loading state renders while engagements list is in-flight', async ({ page, context, }) => { await seedTokenInStorage(context, redteamToken); // Stall the list endpoint so the LoadingState becomes visible. const handler = async (route: import('@playwright/test').Route) => { const url = route.request().url(); if (/\/api\/engagements(\?.*)?$/.test(url)) { await new Promise((r) => setTimeout(r, 1500)); } try { await route.continue(); } catch { /* route may have been torn down by the time we resume */ } }; await page.route('**/api/engagements**', handler); const navPromise = page.goto('/engagements'); await expect(page.getByText(/loading engagements/i)).toBeVisible({ timeout: 3_000 }); await navPromise; await page.unroute('**/api/engagements**', handler); }); test('AC-5.3c — error state renders when the engagements list returns 500', async ({ page, context, }) => { await seedTokenInStorage(context, redteamToken); const handler = async (route: import('@playwright/test').Route) => { const url = route.request().url(); if (/\/api\/engagements(\?.*)?$/.test(url)) { await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Forced failure' }), }); return; } try { await route.continue(); } catch { /* route teardown race — harmless */ } }; await page.route('**/api/engagements**', handler); await page.goto('/engagements'); await expect(page.getByTestId('error-state')).toBeVisible({ timeout: 8_000 }); await expect(page.getByTestId('error-state')).toContainText(/forced failure/i); await expect(page.getByRole('button', { name: /retry/i })).toBeVisible(); await page.unroute('**/api/engagements**', handler); }); });