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(); return { 'X-CSRF-TOKEN': (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string }; } async function capture(page: Page, info: TestInfo, state: string) { await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await page.screenshot({ path: info.outputPath(`${state}.png`) }); } async function scan(page: Page, info: TestInfo, state: string) { const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' }); expect(result.violations).toEqual([]); } for (const [mode, language] of [['light', 'en'], ['dark', 'nb'], ['light', 'nb'], ['dark', 'en']] as const) { test(`[PRES-C05 C06 C07 C10 W05 E08] ${mode}/${language} CV frame navigation, actions and pending editing`, async ({ page, context, playwright }, info) => { test.setTimeout(180_000); const t = translations[language]; const label = (key: keyof typeof t, section: string) => t[key].replace('{section}', section); const admin = await playwright.request.newContext(); const adminHeaders = await login(admin, 'e2e@example.test'); const email = `p509-${Date.now()}-${mode}-${language}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200); await admin.dispose(); const headers = await login(page.request, email); await context.route('**/*', route => { const url = new URL(route.request().url()); return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort(); }); await context.addInitScript(({ mode, language }) => { localStorage.setItem('jobtracker.themeMode', mode); localStorage.setItem('uiLanguage', language); }, { mode, language }); expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); const profile = { version: '1', contact: { fullName: 'Åse Eksempel', headline: 'Canonical career information', location: 'Oslo, Norge' }, summary: ['Verified source experience.'], jobs: [{ id: 'job-1', title: 'Platform engineer', company: 'Nordlys AS', startDate: '2021-01', isCurrent: true, bullets: ['Maintained verified, accessible workflows.'] }], education: [{ id: 'education-1', qualification: 'Computer science', institution: 'Fixture University', startDate: '2016-08', endDate: '2020-06' }], projects: [{ id: 'project-1', name: 'Evidence workspace', bullets: ['Documented source-grounded reviews.'] }], skills: ['C#', 'React', 'Norsk'], }; expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200); const source = await (await page.request.get(`${apiUrl}/career/profile`)).json(); const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys fixture AS' } }); expect(company.status()).toBe(201); const job = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: (await company.json()).id, jobTitle: 'Application-linked CV fixture', status: 'Saved' } }); expect(job.status()).toBe(201); const jobId = (await job.json()).id; const created = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Åse — dokumenterte kvalifikasjoner og erfaring', jobApplicationId: jobId, settings: { themeId: 'minimal', sections: [{ key: 'summary' }, { key: 'experience' }, { key: 'education' }, { key: 'projects' }, { key: 'skills', hidden: true }], customSections: [{ key: 'volunteer', title: 'Frivillig arbeid og dokumenterte bidrag', contentType: 'paragraphs', items: ['Eksisterende innhold — æøå.'] }], } } }); expect(created.status()).toBe(200); const cv = await created.json(); const cvUrl = `${apiUrl}/cv/variants/${cv.id}`; const read = async () => (await page.request.get(cvUrl)).json(); const saved = async () => { await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); }; await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(`/career/builder/${cv.id}`); await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(cv.name); await expect(page.getByRole('tab', { name: t.cvEditorContent, exact: true })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tabpanel', { name: t.cvEditorContent, exact: true })).toBeVisible(); await expect(page.getByText(t.cvEditorScope, { exact: true })).toBeVisible(); await expect(page.getByRole('link', { name: t.cvEditorLinkedApplication })).toHaveAttribute('href', `/jobs/${jobId}`); await expect(page.getByTitle(t.cvEditorPreviewPage.replace('{page}', '1'), { exact: true })).toBeVisible(); await saved(); await capture(page, info, 'desktop-content'); await scan(page, info, 'desktop-content'); const sectionSelect = page.getByRole('combobox', { name: new RegExp(`^${t.cvEditorGoToSection}`) }); await sectionSelect.click(); await page.getByRole('option', { name: `${t.cvSectionSkills} · ${t.cvEditorOmitted}`, exact: true }).click(); await expect(page.getByRole('button', { name: label('cvEditorCollapseSection', t.cvSectionSkills), exact: true })).toBeFocused(); await expect(page.getByRole('button', { name: label('cvEditorShowSection', t.cvSectionSkills), exact: true })).toBeVisible(); expect((await read()).version).toBe(cv.version); await capture(page, info, 'omitted-section-focus'); // Native HTML drag path and the existing keyboard alternative both change the // persisted document order; no simulated request or alternative order model. await page.getByRole('group', { name: t.cvSectionProjects, exact: true }).dragTo(page.getByRole('group', { name: t.cvSectionExperience, exact: true })); await saved(); await expect.poll(async () => (await read()).settings.sections.slice(0, 4).map((row: { key: string }) => row.key)).toEqual(['summary', 'projects', 'experience', 'education']); const move = page.getByRole('button', { name: label('cvEditorMoveSectionDown', t.cvSectionExperience), exact: true }); await move.focus(); await page.keyboard.press('Enter'); await expect(move).toBeFocused(); await saved(); expect((await read()).settings.sections.slice(0, 4).map((row: { key: string }) => row.key)).toEqual(['summary', 'projects', 'education', 'experience']); await capture(page, info, 'reordered-section-focus'); await sectionSelect.focus(); await page.keyboard.press('Space'); await page.keyboard.press('End'); await page.keyboard.press('Enter'); await expect(page.getByRole('button', { name: label('cvEditorCollapseSection', 'Frivillig arbeid og dokumenterte bidrag'), exact: true })).toBeFocused(); let release!: () => void; const hold = new Promise(resolve => { release = resolve; }); let pending = false; await page.route(cvUrl, async route => { if (route.request().method() === 'PUT') { pending = true; await hold; } await route.continue(); }); const headline = page.getByLabel(t.cvEditorHeadlineOverride, { exact: true }); const editing = page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true }); try { await headline.fill('Local CV draft — æøå'); await expect.poll(() => pending).toBe(true); await expect(page.getByText(t.cvEditorSaving, { exact: true })).toBeVisible(); await capture(page, info, 'pending-save'); await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click(); await expect(page.getByLabel(t.cvEditorPageSize, { exact: true })).toBeVisible(); await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click(); await expect(page.getByRole('button', { name: t.cvEditorAllTemplates, exact: true })).toBeVisible(); await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click(); await expect(headline).toHaveValue('Local CV draft — æøå'); await expect(sectionSelect).toContainText('Frivillig arbeid og dokumenterte bidrag'); for (const width of [899, 900, 1024]) { await headline.focus(); await page.setViewportSize({ width, height: 900 }); await expect(headline).toBeFocused(); await expect(editing).toBeVisible(); await expect(headline).toHaveValue('Local CV draft — æøå'); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); await capture(page, info, `pending-${width}`); } } finally { release(); } await saved(); await page.unroute(cvUrl); expect((await read()).settings.headline).toBe('Local CV draft — æøå'); expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(source); await page.getByRole('button', { name: t.cvEditorUndo, exact: true }).click(); await saved(); await expect(headline).toHaveValue(''); await page.getByRole('button', { name: t.cvEditorRedo, exact: true }).click(); await saved(); await expect(headline).toHaveValue('Local CV draft — æøå'); // Publication and PDF stay in the existing client handlers. Menu Escape returns // focus; public state is explicit, and unpublishing revokes anonymous access. const more = page.getByRole('button', { name: t.cvEditorMoreActions, exact: true }); await more.click(); await page.keyboard.press('Escape'); await expect(more).toBeFocused(); await more.click(); await page.getByRole('menuitem', { name: t.cvEditorShare, exact: true }).click(); await expect(page.getByText(t.cvEditorShared, { exact: true })).toBeVisible(); const publicCv = await read(); const anonymous = await playwright.request.newContext(); expect((await anonymous.get(`${apiUrl}/public-cv/${publicCv.publicSlug}`)).status()).toBe(200); await more.click(); await expect(page.getByRole('menuitem', { name: t.cvEditorCopyLink, exact: true })).toBeVisible(); await page.getByRole('menuitem', { name: t.cvEditorStopSharing, exact: true }).click(); await expect(page.getByText(t.cvEditorShared, { exact: true })).toHaveCount(0); expect((await anonymous.get(`${apiUrl}/public-cv/${publicCv.publicSlug}`)).status()).toBe(404); await anonymous.dispose(); const pdfResponse = page.waitForResponse(response => response.url() === `${cvUrl}/export-pdf` && response.request().method() === 'POST'); const download = page.waitForEvent('download'); await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click(); expect((await pdfResponse).status()).toBe(200); expect((await download).suggestedFilename()).toMatch(/\.pdf$/i); await page.setViewportSize({ width: 1440, height: 900 }); await page.getByRole('tab', { name: t.cvEditorHistory, exact: true }).click(); const history = page.getByRole('tabpanel', { name: t.cvEditorHistory, exact: true }); await expect(history.getByText(t.cvEditorVersion.replace('{version}', '1'), { exact: true })).toBeVisible(); await capture(page, info, 'history'); await scan(page, info, 'history'); await page.getByRole('tab', { name: t.cvEditorAiTools, exact: true }).click(); await expect(page.getByRole('tabpanel', { name: t.cvEditorAiTools, exact: true })).toBeVisible(); await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click(); await expect(sectionSelect).toContainText('Frivillig arbeid og dokumenterte bidrag'); await expect(page.getByRole('button', { name: label('cvEditorCollapseSection', 'Frivillig arbeid og dokumenterte bidrag'), exact: true })).toBeVisible(); for (const width of [768, 390, 320]) { await headline.focus(); await page.setViewportSize({ width, height: 844 }); await expect(editing).toBeVisible(); await expect(page.getByRole('button', { name: t.cvEditorEdit, exact: true })).toHaveAttribute('aria-pressed', 'true'); await page.getByLabel(t.cvEditorName, { exact: true }).scrollIntoViewIfNeeded(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); await capture(page, info, `edit-${width}`); await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click(); await expect(page.getByRole('button', { name: t.cvEditorPreview, exact: true })).toHaveAttribute('aria-pressed', 'true'); const preview = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true }); await expect(preview).toBeVisible(); await preview.focus(); await page.keyboard.press('End'); await capture(page, info, `preview-${width}`); await page.getByRole('button', { name: t.cvEditorEdit, exact: true }).click(); await expect(headline).toHaveValue('Local CV draft — æøå'); } await scan(page, info, 'phone-edit'); await page.setViewportSize({ width: 1440, height: 900 }); await more.click(); const copiedResponse = page.waitForResponse(response => response.url() === `${cvUrl}/duplicate`); await page.getByRole('menuitem', { name: t.cvEditorDuplicate, exact: true }).click(); const copied = await (await copiedResponse).json(); await expect(page).toHaveURL(new RegExp(`/career/builder/${copied.id}$`)); expect((await (await page.request.get(`${apiUrl}/cv/variants/${copied.id}`)).json()).settings).toEqual((await read()).settings); }); }