Files
jobtrackingapp/job-tracker-ui/e2e/dashboard-presentation.spec.ts
T
2026-10-06 22:58:45 +02:00

193 lines
14 KiB
TypeScript

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<void>(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();
});