128 lines
9.4 KiB
TypeScript
128 lines
9.4 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(cookie => cookie.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-W01 W02 W03 J05 E08 E09] native Job header/Overview retains actions, custom status, aliases and mobile progress keyboard access', async ({ page, playwright }, info) => {
|
||
test.setTimeout(240000);
|
||
const admin = await playwright.request.newContext();
|
||
const { headers: adminHeaders } = await login(admin, 'e2e@example.test');
|
||
const email = `job-overview-${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 karrieremuligheter og langsiktig plattformutvikling æøå', source: 'direct' } });
|
||
expect(companyResponse.status()).toBe(201);
|
||
const companyId = (await companyResponse.json()).id;
|
||
const title = 'Senior utvikler og faglig rådgiver for samfunnskritiske plattformer og inkluderende digitale tjenester';
|
||
const jobData = { companyId, jobTitle: title, status: 'Interview', location: 'Tromsø · Oslo og fjernarbeid', salary: '700 000–900 000 NOK', deadline: '2026-12-14T12:00:00Z', dateApplied: '2026-10-02T12:00:00Z', followUpAt: '2026-10-12T12:00:00Z', jobUrl: 'https://example.test/jobs/platform', notes: 'Ask about ownership and sustainable delivery.', description: 'Build reliable services with .NET, SQL and Azure.', tags: 'Azure, .NET', source: 'direct', countryCode: 'NO' };
|
||
const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: jobData });
|
||
expect(created.status()).toBe(201); const id = (await created.json()).id;
|
||
const sparseResponse = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: 'Sparse application', status: 'Saved' } });
|
||
expect(sparseResponse.status()).toBe(201); const sparseId = (await sparseResponse.json()).id;
|
||
|
||
const writes: string[] = [];
|
||
page.on('request', request => {
|
||
if (request.url().startsWith(apiUrl) && !['GET', 'OPTIONS'].includes(request.method())) writes.push(`${request.method()} ${new URL(request.url()).pathname}`);
|
||
});
|
||
await page.goto(`/jobs/${id}`);
|
||
await expect(page.getByRole('heading', { name: title, level: 2 })).toBeVisible();
|
||
await expect(page.getByRole('tablist', { name: translations.en.workspaceSections }).getByRole('tab')).toHaveCount(7);
|
||
await expect(page.getByRole('link', { name: translations.en.workspaceOpenAdvert })).toHaveAttribute('href', jobData.jobUrl);
|
||
await expect(page.getByRole('link', { name: translations.en.workspaceOpenAdvert })).toHaveAttribute('rel', 'noopener noreferrer');
|
||
expect(writes).toEqual([]);
|
||
const edit = page.getByRole('button', { name: translations.en.workspaceEditApplication });
|
||
await edit.focus(); await page.keyboard.press('Enter');
|
||
await expect(page.getByRole('dialog')).toBeVisible();
|
||
await page.keyboard.press('Escape'); await expect(edit).toBeFocused();
|
||
|
||
for (const language of ['en', 'nb'] as const) for (const mode of ['light', 'dark'] as const) {
|
||
const t = translations[language];
|
||
await page.evaluate(({ mode, language }) => { localStorage.setItem('jobtracker.themeMode', mode); localStorage.setItem('uiLanguage', language); }, { mode, language });
|
||
// Persist language while no app reads are in flight; theme uses its existing local owner.
|
||
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(`/jobs/${id}?section=overview`);
|
||
await expect(page.getByRole('heading', { name: title, level: 2 })).toBeVisible();
|
||
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', mode);
|
||
const summary = page.getByRole('region', { name: t.workspaceApplicationInfo, exact: true });
|
||
await expect(summary).toContainText(new Date(jobData.dateApplied).toLocaleDateString(language === 'nb' ? 'nb-NO' : 'en'));
|
||
const progress = page.getByRole('region', { name: `${t.workspaceProgress}: ${t.statusInterview}` });
|
||
await expect(progress.locator('[aria-current=step]')).toHaveText(t.statusInterview);
|
||
for (const [width, height, name] of [[1440, 900, 'desktop'], [768, 1024, 'tablet'], [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, `job-overview-${name}-${mode}-${language}`);
|
||
}
|
||
await progress.focus();
|
||
expect(await progress.evaluate(element => parseFloat(getComputedStyle(element).outlineWidth))).toBeGreaterThanOrEqual(3);
|
||
await progress.evaluate(element => { element.scrollLeft = 0; });
|
||
await page.keyboard.press('ArrowRight');
|
||
await expect.poll(() => progress.evaluate(element => element.scrollLeft)).toBeGreaterThan(0);
|
||
await capture(page, info, `job-progress-focus-${mode}-${language}`);
|
||
if (language === 'en') {
|
||
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||
await info.attach(`job-overview-${mode}-axe`, { body: JSON.stringify({ violations: scan.violations, incomplete: scan.incomplete }), contentType: 'application/json' });
|
||
expect(scan.violations).toEqual([]);
|
||
}
|
||
}
|
||
for (const width of [320, 1920]) {
|
||
await page.setViewportSize({ width, height: 900 });
|
||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||
await capture(page, info, `job-overview-${width}-dark-nb`);
|
||
}
|
||
// Existing native status write, not a new header status API or inferred progression.
|
||
await page.goto('about:blank');
|
||
expect((await page.request.put(`${apiUrl}/jobapplications/${id}`, { headers, data: { ...jobData, status: 'Faglig vurdering' } })).status()).toBe(204);
|
||
await page.goto(`/applications/${id}?section=overview`);
|
||
const custom = page.getByRole('region', { name: `${translations.nb.workspaceProgress}: Faglig vurdering` });
|
||
await expect(custom).toContainText(translations.nb.workspaceCustomProgress);
|
||
await expect(custom.locator('[aria-current]')).toHaveCount(0);
|
||
await capture(page, info, 'job-custom-status-dark-nb');
|
||
await page.goto(`/jobs/${sparseId}`);
|
||
await expect(page.getByRole('heading', { name: 'Sparse application' })).toBeVisible();
|
||
await expect(page.getByRole('region', { name: translations.nb.workspaceApplicationInfo }).getByText('—', { exact: true })).toHaveCount(3);
|
||
await expect(page.getByRole('link', { name: translations.nb.workspaceOpenAdvert })).toHaveCount(0);
|
||
await capture(page, info, 'job-sparse-dark-nb');
|
||
const foreignEmail = `job-overview-foreign-${Date.now()}@example.test`;
|
||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email: foreignEmail, password, roles: [] } })).status()).toBe(200);
|
||
const foreign = await playwright.request.newContext();
|
||
await login(foreign, foreignEmail);
|
||
expect((await foreign.get(`${apiUrl}/jobapplications/${id}/workspace`)).status()).toBe(404);
|
||
await foreign.dispose(); await admin.dispose();
|
||
});
|
||
|
||
test('[PRES-W01 W02 E08] Job Overview loading, failed read, retry and unavailable resource remain explicit', async ({ page }, info) => {
|
||
await login(page.request, 'e2e@example.test');
|
||
let release: (() => void) | undefined; let failed = true;
|
||
const hold = new Promise<void>(resolve => { release = resolve; });
|
||
await page.route(`${apiUrl}/jobapplications/2147483646/workspace`, async route => {
|
||
await hold;
|
||
return route.fulfill({ status: failed ? 503 : 404, json: { message: failed ? 'Temporary workspace failure' : 'Not Found' } });
|
||
});
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.goto('/jobs/2147483646');
|
||
await expect(page.getByRole('progressbar').first()).toBeVisible();
|
||
await capture(page, info, 'job-loading'); release!();
|
||
await expect(page.getByRole('alert').filter({ hasText: 'Could not open this application.' })).toBeVisible();
|
||
await capture(page, info, 'job-load-error'); failed = false;
|
||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||
await expect(page.getByRole('alert').filter({ hasText: 'Not Found' })).toBeVisible();
|
||
await expect(page.getByRole('button', { name: 'Retry', exact: true })).toHaveCount(0);
|
||
await expect(page.getByRole('button', { name: 'Back to applications' })).toBeVisible();
|
||
await capture(page, info, 'job-unavailable');
|
||
});
|