import { expect, test as base, type APIRequestContext, type BrowserContext, type Page, type TestInfo } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; import { translations } from '../src/i18n/translations'; import type { DashboardLayout } from '../src/dashboardLayout'; const apiUrl = 'http://localhost:5302/api'; const layoutUrl = `${apiUrl}/auth/preferences/dashboard`; const password = 'E2ePassword123!'; const ids = ['cards', 'attention', 'funnel', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']; type Account = { email: string; headers: Record }; async function login(api: APIRequestContext, email: string) { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await (await api.get(`${apiUrl}/auth/me`)).json(); const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value; return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string }; } async function localOnly(context: BrowserContext) { await context.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort()); } async function readLayout(api: APIRequestContext): Promise { const response = await api.get(layoutUrl); expect(response.status()).toBe(200); return response.json(); } const editor = (page: Page) => page.getByRole('region', { name: 'Editing dashboard', exact: true }); async function openEditor(page: Page) { await page.getByRole('button', { name: 'Customize dashboard', exact: true }).click(); await expect(editor(page).getByRole('checkbox')).toHaveCount(9); } async function save(page: Page) { const response = page.waitForResponse(response => response.url() === layoutUrl && response.request().method() === 'PUT'); await editor(page).getByRole('button', { name: 'Save layout', exact: true }).click(); expect((await response).status()).toBe(200); await expect(editor(page)).toHaveCount(0); } async function confirm(page: Page, name: string) { await page.getByRole('dialog', { name, exact: true }).getByRole('button', { name, exact: true }).click(); } async function capture(page: Page, info: TestInfo, name: string) { const path = info.outputPath(`${name}.png`); await page.screenshot({ path, fullPage: true }); await info.attach(name, { path, contentType: 'image/png' }); } async function signOut(page: Page) { await page.getByRole('button', { name: 'User', exact: true }).click(); await page.getByRole('menuitem', { name: 'Sign out', exact: true }).click(); await expect(page).toHaveURL(/\/login\?sessionChanged=1$/); } async function signIn(page: Page, email: string) { await page.getByLabel('Username or email', { exact: true }).fill(email); await page.getByLabel('Current password', { exact: true }).fill(password); await page.getByRole('button', { name: 'Sign in', exact: true }).click(); await expect(page).toHaveURL(/\/dashboard$/); } const test = base.extend<{ account: Account }>({ account: async ({ page, playwright }, provide) => { const admin = await playwright.request.newContext(); try { const adminHeaders = await login(admin, 'e2e@example.test'); const email = `phase8-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200); const headers = await login(page.request, email); const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Phase eight fixture' } }); expect(company.status()).toBe(201); const companyId = (await company.json()).id; expect((await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: 'Local fixture developer', status: 'Applied', dateApplied: new Date().toISOString(), tags: 'TypeScript', source: 'NAV' } })).status()).toBe(201); await localOnly(page.context()); await page.goto('/dashboard'); await expect(page.getByRole('link', { name: 'Tracked jobs: 1', exact: true })).toBeVisible(); const dismiss = page.getByRole('button', { name: 'Dismiss setup checklist' }); if (await dismiss.isVisible()) await dismiss.click(); await provide({ email, headers }); } finally { await admin.dispose(); } }, }); test('[P8] Saved order, size and visibility survive HTTP readback, a fresh browser and logout/login', async ({ page, browser, account }, info) => { await openEditor(page); await editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true }).click(); await editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true }).selectOption('full'); await editor(page).getByRole('checkbox', { name: 'Application activity', exact: true }).uncheck(); await save(page); const saved = await readLayout(page.request); expect(saved.version).toBe(1); expect(saved.revision).toBe(1); expect(saved.widgets.map(widget => widget.id)).toEqual(['cards', 'funnel', 'attention', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']); expect(saved.widgets.find(widget => widget.id === 'funnel')).toEqual({ id: 'funnel', visible: true, size: 'full' }); expect(saved.widgets.find(widget => widget.id === 'activity')?.visible).toBe(false); const assertSaved = async (target: Page) => { await expect(target.locator('[data-dashboard-widget="activity"]')).toHaveCount(0); await expect(target.locator('[data-dashboard-widget="funnel"]')).toHaveAttribute('data-widget-size', 'full'); await expect.poll(() => target.locator('[data-dashboard-widget]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-dashboard-widget')))).toEqual(saved.widgets.filter(widget => widget.visible).map(widget => widget.id)); }; await page.reload(); await assertSaved(page); const fresh = await browser.newContext({ baseURL: 'http://localhost:3300' }); try { await localOnly(fresh); await login(fresh.request, account.email); const other = await fresh.newPage(); await other.goto('/dashboard'); await assertSaved(other); expect(await readLayout(fresh.request)).toEqual(saved); await capture(other, info, 'fresh-browser-server-layout'); } finally { await fresh.close(); } await signOut(page); await signIn(page, account.email); await assertSaved(page); expect(await readLayout(page.request)).toEqual(saved); }); test('[P8] Entering edit mode keeps its heading and save controls below the fixed app header', async ({ page, account }) => { expect(account.email).toContain('phase8-'); for (const width of [320, 768, 1440]) { await page.setViewportSize({ width, height: 900 }); await openEditor(page); const heading = editor(page).getByRole('heading', { name: 'Editing dashboard', exact: true }); await expect.poll(() => heading.evaluate(element => { const header = document.querySelector('.MuiAppBar-root'); return element.getBoundingClientRect().top >= (header?.getBoundingClientRect().bottom ?? 0); })).toBe(true); await editor(page).getByRole('button', { name: 'Cancel', exact: true }).click(); } }); test('[P8] Cancel, rejected navigation and reset change only the draft until explicit Save', async ({ page, account }) => { void account; await openEditor(page); await editor(page).getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck(); await save(page); const before = await readLayout(page.request); let writes = 0; page.on('request', request => { if (request.url() === layoutUrl && request.method() === 'PUT') writes++; }); await openEditor(page); await editor(page).getByRole('checkbox', { name: 'Application activity', exact: true }).uncheck(); await page.getByRole('link', { name: 'Tracked jobs: 1', exact: true }).click(); await page.getByRole('dialog', { name: 'Discard dashboard changes?', exact: true }).getByRole('button', { name: 'Keep editing', exact: true }).click(); await expect(page).toHaveURL(/\/dashboard$/); await expect(editor(page).getByRole('checkbox', { name: 'Application activity', exact: true })).not.toBeChecked(); await editor(page).getByRole('button', { name: 'Cancel', exact: true }).click(); await page.getByRole('dialog', { name: 'Discard dashboard changes?', exact: true }).getByRole('button', { name: 'Discard changes', exact: true }).click(); await expect(page.getByRole('button', { name: 'Customize dashboard', exact: true })).toBeFocused(); expect(await readLayout(page.request)).toEqual(before); expect(writes).toBe(0); await openEditor(page); await editor(page).getByRole('button', { name: 'Reset to default', exact: true }).click(); await page.getByRole('dialog', { name: 'Reset to default', exact: true }).getByRole('button', { name: 'Cancel', exact: true }).click(); await expect(editor(page).getByRole('checkbox', { name: 'Job tags', exact: true })).not.toBeChecked(); await editor(page).getByRole('button', { name: 'Reset to default', exact: true }).click(); await confirm(page, 'Reset to default'); for (const checkbox of await editor(page).getByRole('checkbox').all()) await expect(checkbox).toBeChecked(); expect(await readLayout(page.request)).toEqual(before); expect(writes).toBe(0); await save(page); const reset = await readLayout(page.request); expect(reset.revision).toBe(before.revision + 1); expect(reset.widgets.map(widget => widget.id)).toEqual(ids); expect(reset.widgets.every(widget => widget.visible)).toBe(true); expect(writes).toBe(1); }); test('[P8] HTTP 503 and offline transport failure retain draft and support an acknowledged retry', async ({ page, account }, info) => { void account; const before = await readLayout(page.request); await openEditor(page); await editor(page).getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck(); await editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true }).selectOption('full'); let failure: '503' | 'offline' | null = '503'; await page.route(layoutUrl, route => route.request().method() !== 'PUT' || !failure ? route.continue() : failure === '503' ? route.fulfill({ status: 503, json: { message: 'Controlled unavailable' } }) : route.abort('internetdisconnected')); for (const mode of ['503', 'offline'] as const) { failure = mode; const failed = mode === '503' ? page.waitForResponse(response => response.url() === layoutUrl && response.status() === 503) : page.waitForEvent('requestfailed', request => request.url() === layoutUrl && request.method() === 'PUT'); await editor(page).getByRole('button', { name: 'Save layout', exact: true }).click(); await failed; await expect(editor(page).getByRole('alert')).toContainText('Your layout could not be saved'); await expect(editor(page).getByRole('button', { name: 'Save layout', exact: true })).toBeEnabled(); await expect(editor(page).getByRole('checkbox', { name: 'Job tags', exact: true })).not.toBeChecked(); await expect(editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true })).toHaveValue('full'); expect(await readLayout(page.request)).toEqual(before); await capture(page, info, `draft-retained-${mode}`); } failure = null; let release!: () => void; const acknowledgement = new Promise(resolve => { release = resolve; }); await page.route(layoutUrl, async route => { if (route.request().method() === 'PUT') await acknowledgement; await route.continue(); }); const saving = save(page); try { await expect(editor(page).getByRole('button', { name: 'Saving layout…', exact: true })).toBeDisabled(); await expect(editor(page).getByRole('button', { name: 'Cancel', exact: true })).toBeDisabled(); for (const checkbox of await editor(page).getByRole('checkbox').all()) await expect(checkbox).toBeDisabled(); for (const select of await editor(page).getByRole('combobox').all()) await expect(select).toBeDisabled(); expect(await readLayout(page.request)).toEqual(before); } finally { release(); } await saving; const saved = await readLayout(page.request); expect(saved.revision).toBe(1); expect(saved.widgets.find(widget => widget.id === 'skills')?.visible).toBe(false); }); test('[P8] Two authenticated sessions cannot overwrite a stale revision and explicitly reload before saving', async ({ page, browser, account }, info) => { const second = await browser.newContext({ baseURL: 'http://localhost:3300' }); try { await localOnly(second); await login(second.request, account.email); const other = await second.newPage(); await other.goto('/dashboard'); await openEditor(page); await openEditor(other); await editor(page).getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck(); await save(page); const firstSaved = await readLayout(page.request); await editor(other).getByRole('checkbox', { name: 'Application activity', exact: true }).uncheck(); const conflict = other.waitForResponse(response => response.url() === layoutUrl && response.request().method() === 'PUT'); await editor(other).getByRole('button', { name: 'Save layout', exact: true }).click(); expect((await conflict).status()).toBe(409); await expect(editor(other).getByRole('button', { name: 'Reload latest layout', exact: true })).toBeVisible(); expect(await readLayout(second.request)).toEqual(firstSaved); await capture(other, info, 'conflicting-layout-draft'); await editor(other).getByRole('button', { name: 'Reload latest layout', exact: true }).click(); await confirm(other, 'Reload latest layout'); await expect(editor(other).getByRole('checkbox', { name: 'Job tags', exact: true })).not.toBeChecked(); await expect(editor(other).getByRole('checkbox', { name: 'Application activity', exact: true })).toBeChecked(); await editor(other).getByRole('checkbox', { name: 'Application activity', exact: true }).uncheck(); await save(other); const final = await readLayout(page.request); expect(final.revision).toBe(2); expect(final.widgets.filter(widget => !widget.visible).map(widget => widget.id)).toEqual(['activity', 'skills']); } finally { await second.close(); } }); test('[P8] Anonymous requests fail, ordinary accounts stay independent and administrator data stays forbidden', async ({ page, playwright, account }) => { const anonymous = await playwright.request.newContext(); const administrator = await playwright.request.newContext(); const other = await playwright.request.newContext(); try { expect((await anonymous.get(layoutUrl)).status()).toBe(401); expect((await anonymous.put(layoutUrl, { data: { version: 1, revision: 0, widgets: [] } })).status()).toBe(401); const adminHeaders = await login(administrator, 'e2e@example.test'); const emailB = `phase8-b-${Date.now()}@example.test`; expect((await administrator.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email: emailB, password, roles: [] } })).status()).toBe(200); const headersB = await login(other, emailB); const untouchedB = await readLayout(other); expect(untouchedB).toEqual({ version: 1, revision: 0, widgets: [] }); await openEditor(page); await editor(page).getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck(); await save(page); const savedA = await readLayout(page.request); expect(await readLayout(other)).toEqual(untouchedB); expect((await other.put(layoutUrl, { headers: headersB, data: { ...savedA, revision: 0, widgets: savedA.widgets.map(widget => ({ ...widget, visible: widget.id === 'cards' })) } })).status()).toBe(200); expect(await readLayout(page.request)).toEqual(savedA); expect((await page.request.get(`${apiUrl}/admin/system`)).status()).toBe(403); expect((await other.get(`${apiUrl}/admin/system`)).status()).toBe(403); await signOut(page); await signIn(page, emailB); await openEditor(page); await expect(editor(page).getByRole('checkbox', { name: 'Summary cards', exact: true })).toBeChecked(); await expect(editor(page).getByRole('checkbox', { name: 'Current pipeline', exact: true })).not.toBeChecked(); await expect(editor(page).getByRole('checkbox', { name: 'Job tags', exact: true })).not.toBeChecked(); expect((await readLayout(page.request)).widgets.filter(widget => widget.visible).map(widget => widget.id)).toEqual(['cards']); await editor(page).getByRole('button', { name: 'Cancel', exact: true }).click(); await signOut(page); await signIn(page, account.email); expect(await readLayout(page.request)).toEqual(savedA); } finally { await anonymous.dispose(); await administrator.dispose(); await other.dispose(); } }); test('[P8] Five legacy hidden flags migrate only on explicit save; all nine hidden have recovery', async ({ page, account }, info) => { void account; expect(await readLayout(page.request)).toEqual({ version: 1, revision: 0, widgets: [] }); await page.evaluate(() => { const owner = localStorage.getItem('authUserKey'); if (!owner || owner === 'anon') throw new Error('Expected authenticated per-account legacy owner'); localStorage.setItem(`dashboardPrefs:${owner}`, JSON.stringify({ cards: false, activity: false, funnel: false, companies: false, skills: false })); }); await page.reload(); await openEditor(page); for (const name of ['Summary cards', 'Application activity', 'Current pipeline', 'Top companies by activity', 'Job tags']) await expect(editor(page).getByRole('checkbox', { name, exact: true })).not.toBeChecked(); expect(await readLayout(page.request)).toEqual({ version: 1, revision: 0, widgets: [] }); await save(page); expect((await readLayout(page.request)).widgets.filter(widget => !widget.visible).map(widget => widget.id)).toEqual(['cards', 'funnel', 'activity', 'companies', 'skills']); await page.evaluate(() => { for (const key of Object.keys(localStorage)) if (key.startsWith('dashboardPrefs:')) localStorage.removeItem(key); }); await page.reload(); await openEditor(page); await expect(editor(page).getByRole('checkbox', { name: 'Summary cards', exact: true })).not.toBeChecked(); for (const checkbox of await editor(page).getByRole('checkbox').all()) await checkbox.uncheck(); await save(page); await page.reload(); await expect(page.getByText('Your dashboard sections are hidden.', { exact: true })).toBeVisible(); await expect(page.locator('[data-dashboard-widget]')).toHaveCount(0); expect((await readLayout(page.request)).widgets.every(widget => !widget.visible)).toBe(true); await capture(page, info, 'all-nine-hidden'); await openEditor(page); await editor(page).getByRole('checkbox', { name: 'Current pipeline', exact: true }).check(); await save(page); await expect(page.locator('[data-dashboard-widget="funnel"]')).toBeVisible(); await expect(page.getByText('Your dashboard sections are hidden.', { exact: true })).toHaveCount(0); }); test('[P8] Editor reflows across EN/NB themes and desktop/tablet/mobile with native accessible controls', async ({ page, account }, info) => { test.setTimeout(180000); for (const language of ['en', 'nb'] as const) for (const mode of ['light', 'dark'] as const) { const t = translations[language]; await page.evaluate(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode }); expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: account.headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); await page.reload(); await page.getByRole('button', { name: t.dashboardCustomize, exact: true }).click(); const panel = page.getByRole('region', { name: t.dashboardEditing, exact: true }); await expect(panel.getByRole('list', { name: t.dashboardManageWidgets })).toBeVisible(); await expect(panel.getByRole('checkbox')).toHaveCount(9); await expect(panel.getByRole('combobox')).toHaveCount(9); await expect(panel.getByRole('combobox').first()).toBeDisabled(); expect(await panel.getByRole('combobox').evaluateAll(nodes => nodes.every(node => node.tagName === 'SELECT'))).toBe(true); for (const [width, height] of [[1440, 900], [768, 1024], [390, 844], [320, 740]]) { await page.setViewportSize({ width, height }); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); await capture(page, info, `editor-${language}-${mode}-${width}`); } const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); await info.attach(`editor-${language}-${mode}-axe`, { body: JSON.stringify({ violations: axe.violations, incomplete: axe.incomplete }), contentType: 'application/json' }); expect(axe.violations).toEqual([]); expect(axe.incomplete.filter(result => result.id === 'aria-prohibited-attr')).toEqual([]); await panel.getByRole('button', { name: t.cancel, exact: true }).click(); await expect(page.getByRole('button', { name: t.dashboardCustomize, exact: true })).toBeFocused(); } }); test('[P8] Keyboard move focus/live status, pointer drag and emulated touch buttons preserve DOM order', async ({ page, browser, account }, info) => { await page.setViewportSize({ width: 1440, height: 1000 }); await openEditor(page); const earlier = editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true }); await earlier.focus(); await page.keyboard.press('Enter'); await expect(earlier).toBeFocused(); await expect(editor(page).getByRole('status')).toHaveText('Current pipeline moved to position 2 of 9.'); await expect.poll(() => editor(page).locator('[data-layout-row]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-layout-row')))).toEqual(['cards', 'funnel', 'attention', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']); await editor(page).getByRole('button', { name: 'Drag Current pipeline to reorder', exact: true }).dragTo(editor(page).locator('[data-layout-row="cards"]')); await expect(editor(page).getByRole('status')).toHaveText('Current pipeline moved to position 1 of 9.'); await expect(editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true })).toBeDisabled(); await save(page); expect((await readLayout(page.request)).widgets[0].id).toBe('funnel'); const touch = await browser.newContext({ baseURL: 'http://localhost:3300', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); try { await localOnly(touch); await login(touch.request, account.email); const mobile = await touch.newPage(); await mobile.goto('/dashboard'); await mobile.getByRole('button', { name: 'Customize dashboard', exact: true }).tap(); await editor(mobile).getByRole('button', { name: 'Move Current pipeline later', exact: true }).tap(); await expect(editor(mobile).getByRole('status')).toHaveText('Current pipeline moved to position 2 of 9.'); await save(mobile); expect((await readLayout(touch.request)).widgets.slice(0, 2).map(widget => widget.id)).toEqual(['cards', 'funnel']); await capture(mobile, info, 'emulated-touch-button-order'); await info.attach('device-evidence-limit', { body: 'Chromium desktop pointer drag and emulated touch button input only. Physical touch, software keyboards and human screen reader acceptance remain untested. Axe incomplete items require review.', contentType: 'text/plain' }); } finally { await touch.close(); } });