import { expect, test, type APIRequestContext, type Page, type TestInfo } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; import { translations } from '../src/i18n/translations'; const apiUrl = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; 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(c => c.name === 'XSRF-TOKEN')!.value; return { me, headers: { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string } }; } 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' }); } test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsive presentation', async ({ page, playwright }, info) => { test.setTimeout(240000); const admin = await playwright.request.newContext(); const { headers: adminHeaders } = await login(admin, 'e2e@example.test'); const email = `dashboard-${Date.now()}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200); const { headers } = await login(page.request, email); await page.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort()); const companyResponse = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys teknologi og fellestjenester æøå', source: 'NAV', location: 'Tromsø' } }); expect(companyResponse.status()).toBe(201); const companyId = (await companyResponse.json()).id; const now = new Date(); for (let index = 0; index < 8; index++) { const date = new Date(now.getFullYear(), now.getMonth() - index % 3, 3).toISOString(); const data = { companyId, jobTitle: `Plattformutvikler og rådgiver ${index}`, status: ['Applied', 'Waiting', 'Interview', 'Offer', 'Rejected', 'Faglig vurdering', 'Saved', 'Preparing'][index], dateApplied: index < 6 ? date : null, tags: 'TypeScript, Azure', salaryMin: 600000, salaryMax: 800000, salaryCurrency: 'NOK', salaryPeriod: 'year' }; const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data }); expect(created.status()).toBe(201); if (index === 2 || index === 3) { const id = (await created.json()).id; expect((await page.request.put(`${apiUrl}/jobapplications/${id}`, { headers, data: { ...data, responseReceived: true, responseDate: date } })).status()).toBe(204); } } await page.setViewportSize({ width: 1440, height: 900 }); await page.goto('/dashboard'); await expect(page.getByRole('link', { name: 'Nordlys teknologi og fellestjenester æøå', exact: true })).toBeVisible(); const dismiss = page.getByRole('button', { name: 'Dismiss setup checklist' }); if (await dismiss.isVisible()) await dismiss.click(); await capture(page, info, 'dashboard-populated-light-en'); const stats = await (await page.request.get(`${apiUrl}/jobapplications/stats`)).json(); expect(stats.active).toBe(8); const monthly = await (await page.request.get(`${apiUrl}/jobapplications/analytics?months=12`)).json(); const applied = monthly.reduce((sum: number, point: { applied: number }) => sum + point.applied, 0); const responses = monthly.reduce((sum: number, point: { responses: number }) => sum + point.responses, 0); await expect(page.getByRole('link', { name: 'Tracked jobs: 8' })).toBeVisible(); await expect(page.getByText(`${responses} / ${applied} recorded responses`, { exact: false })).toBeVisible(); const activity = page.getByRole('region', { name: 'Application activity' }); const monthlyControl = activity.getByText('View monthly values'); await monthlyControl.focus(); await page.keyboard.press('Enter'); await expect(activity.getByRole('table')).toBeVisible(); await expect(monthlyControl).toBeFocused(); const ring = await monthlyControl.evaluate(element => getComputedStyle(element).outlineWidth); expect(parseFloat(ring)).toBeGreaterThanOrEqual(3); await capture(page, info, 'dashboard-monthly-values-keyboard'); for (const point of monthly) { const month = new Intl.DateTimeFormat('en-GB', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${point.month}-01T12:00:00Z`)); await expect(activity.getByRole('row', { name: `${month} ${point.applied} ${point.responses}`, exact: true })).toBeVisible(); } await activity.getByText('View monthly values').click(); await page.getByText('Recorded status breakdown', { exact: true }).click(); await page.getByRole('link', { name: 'Faglig vurdering', exact: true }).click(); await expect(page).toHaveURL(/\/jobs\?status=Faglig\+vurdering/); await expect(page.locator('[data-job-row-id]')).toHaveCount(1); await page.goBack(); await page.getByRole('link', { name: 'Nordlys teknologi og fellestjenester æøå', exact: true }).click(); await expect(page).toHaveURL(new RegExp(`/jobs\\?companyId=${companyId}`)); await expect(page.locator('[data-job-row-id]')).toHaveCount(8); await page.goBack(); for (const mode of ['light', 'dark'] as const) for (const language of ['en', 'nb'] as const) { const t = translations[language]; await page.evaluate(({ mode, language }) => { localStorage.setItem('jobtracker.themeMode', mode); localStorage.setItem('uiLanguage', language); }, { mode, language }); // Configure the native preference before mounting the next visual fixture. await page.goto('about:blank'); expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); await page.goto('/dashboard'); await expect(page.getByText(t.dashboardTrackedJobs, { exact: true })).toBeVisible(); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', mode); for (const [width, height, name] of [[1440, 900, 'desktop'], [390, 844, 'phone']] as const) { await page.setViewportSize({ width, height }); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); await capture(page, info, `dashboard-${name}-${mode}-${language}`); } const customize = page.getByRole('button', { name: t.dashboardCustomize, exact: true }); await customize.focus(); await page.keyboard.press('Enter'); await expect(page.getByRole('menuitemcheckbox').first()).toBeFocused(); if (language === 'en') { // Measure settled colors, not the menu's entry-opacity transition. await expect.poll(() => page.getByRole('menu').evaluate(menu => getComputedStyle(menu.parentElement!).opacity)).toBe('1'); const menuScan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); await info.attach(`dashboard-${mode}-menu-axe`, { body: JSON.stringify({ violations: menuScan.violations, incomplete: menuScan.incomplete }), contentType: 'application/json' }); expect(menuScan.violations).toEqual([]); } await page.keyboard.press('Escape'); await expect(customize).toBeFocused(); await expect(page.locator('[role="menuitemcheckbox"]')).toHaveCount(0); if (language === 'en') { const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); await info.attach(`dashboard-${mode}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' }); expect(result.violations).toEqual([]); } } for (const [width, height] of [[320, 740], [768, 1024], [1024, 768], [1920, 1080]]) { await page.setViewportSize({ width, height }); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); await capture(page, info, `dashboard-${width}-dark-nb`); } await page.emulateMedia({ reducedMotion: 'reduce' }); await page.setViewportSize({ width: 1440, height: 900 }); expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: 'en' } })).status()).toBe(204); await page.evaluate(() => localStorage.setItem('uiLanguage', 'en')); await page.reload(); await page.getByRole('button', { name: 'Customize dashboard' }).click(); for (const item of await page.getByRole('menuitemcheckbox').all()) await item.click(); await page.keyboard.press('Escape'); await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible(); await page.reload(); await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible(); for (const name of ['Application activity', 'Current pipeline', 'Top companies by activity', 'Job tags']) await expect(page.getByRole('region', { name })).toHaveCount(0); await capture(page, info, 'dashboard-all-hidden'); const emailB = `dashboard-b-${Date.now()}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email: emailB, password, roles: [] } })).status()).toBe(200); 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$/); await page.getByLabel('Username or email', { exact: true }).fill(emailB); await page.getByLabel('Current password', { exact: true }).fill(password); await page.getByRole('button', { name: 'Sign in', exact: true }).click(); await expect(page).toHaveURL(/\/dashboard$/); await expect(page.getByText('Your search starts here')).toBeVisible(); await expect(page.getByText('Your dashboard sections are hidden.')).toHaveCount(0); await expect(page.getByText('Nordlys teknologi og fellestjenester æøå', { exact: true })).toHaveCount(0); await capture(page, info, 'dashboard-new-account'); await page.getByRole('button', { name: 'Customize dashboard' }).click(); for (const item of await page.getByRole('menuitemcheckbox').all()) await expect(item).toHaveAttribute('aria-checked', 'true'); await page.keyboard.press('Escape'); const b = await login(page.request, emailB); const bCompany = await (await page.request.post(`${apiUrl}/companies`, { headers: b.headers, data: { name: 'New search' } })).json(); expect((await page.request.post(`${apiUrl}/jobapplications`, { headers: b.headers, data: { companyId: bCompany.id, jobTitle: 'First prospect', status: 'Saved' } })).status()).toBe(201); await page.reload(); await expect(page.getByRole('link', { name: 'Tracked jobs: 1' })).toBeVisible(); await expect(page.getByText('No applications or dated responses in this period.')).toBeVisible(); await capture(page, info, 'dashboard-one-prospect-zero-activity'); 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$/); 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.getByText('Your dashboard sections are hidden.')).toBeVisible(); await expect(page.getByText('First prospect', { exact: true })).toHaveCount(0); await admin.dispose(); }); test('[PRES-S01 E08] Dashboard loading and independent failed resources retain retry and exact values', async ({ page }, info) => { await login(page.request, 'e2e@example.test'); let failSummary = false; let failTrends = true; let release: (() => void) | undefined; const hold = new Promise(resolve => { release = resolve; }); await page.route(`${apiUrl}/jobapplications/stats`, async route => { await hold; return failSummary ? route.fulfill({ status: 503, json: { message: 'Summary unavailable' } }) : route.fulfill({ json: { total: 4, active: 4, deleted: 0, byStatus: { Applied: 4 }, appliedLast30Days: 4 } }); }); await page.route(`${apiUrl}/jobapplications/analytics-overview`, route => route.fulfill({ json: { funnel: [{ label: 'Applied', count: 4 }], topCompanies: [], responseRateBySource: [], totalActive: 4, totalResponses: 1 } })); await page.route(`${apiUrl}/jobapplications/analytics?*`, async route => { await hold; return failTrends ? route.fulfill({ status: 503, json: { message: 'Trends unavailable' } }) : route.fulfill({ json: [{ month: '2026-08', applied: 4, responses: 1 }] }); }); await page.goto('/dashboard'); await expect(page.getByRole('progressbar').first()).toBeVisible(); await capture(page, info, 'dashboard-loading'); release!(); await expect(page.getByText('Unable to load dashboard trends')).toBeVisible(); await expect(page.getByRole('link', { name: 'Tracked jobs: 4' })).toBeVisible(); await capture(page, info, 'dashboard-trends-error'); failTrends = false; await page.getByRole('button', { name: 'Retry', exact: true }).click(); await expect(page.getByText('1 / 4 recorded responses', { exact: false })).toBeVisible(); failSummary = true; await page.reload(); await expect(page.getByText('Unable to load dashboard summary')).toBeVisible(); await expect(page.getByRole('region', { name: 'Application activity' })).toBeVisible(); await capture(page, info, 'dashboard-summary-error'); failSummary = false; await page.getByRole('button', { name: 'Retry', exact: true }).click(); await expect(page.getByRole('link', { name: 'Tracked jobs: 4' })).toBeVisible(); });