import { expect, test, type APIRequestContext, type Locator, type Page, type TestInfo } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; import type { CvOutline, CvVariant } from '../src/cvBuilder'; 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(scope: Locator, info: TestInfo, state: string) { await scope.scrollIntoViewIfNeeded(); await scope.screenshot({ path: info.outputPath(`${state}.png`), animations: 'disabled' }); } async function scan(page: Page, info: TestInfo, state: string, region: string) { const result = await new AxeBuilder({ page }).include(`[role="region"][aria-label=${JSON.stringify(region)}]`) .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 C07 C10 W05 E08] ${mode}/${language} CV field scope, source resets, entry order and custom content`, async ({ page, context, playwright }, info) => { test.setTimeout(180_000); const t = translations[language]; const named = (key: keyof typeof t, entry: string) => t[key].replace('{entry}', entry); const sectionLabel = (key: keyof typeof t, section: string) => t[key].replace('{section}', section); const resetLabel = (field: string) => t.cvEditorResetField.replace('{field}', field); const admin = await playwright.request.newContext(); try { const adminHeaders = await login(admin, 'e2e@example.test'); const email = `p510-${Date.now()}-${mode}-${language}@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 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.'] }, { id: 'job-2', title: 'Systems engineer', company: 'Fjord AS', startDate: '2019-01', endDate: '2020-12', bullets: ['Reviewed documented integrations.'] }, { id: 'job-3', title: 'Software developer', company: 'Aurora AS', startDate: '2017-01', endDate: '2018-12', bullets: ['Built source-grounded software.'] }, ], education: [{ id: 'education-1', qualification: 'Computer science', institution: 'Fixture University', startDate: '2013-08', endDate: '2016-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 outline: CvOutline = await (await page.request.get(`${apiUrl}/cv/outline`)).json(); const entries = outline.sections.find(section => section.key === 'experience')!.entries; const first = entries.find(entry => entry.title === 'Platform engineer')!; const second = entries.find(entry => entry.title === 'Systems engineer')!; const third = entries.find(entry => entry.title === 'Software developer')!; expect(entries).toHaveLength(3); const settings = { themeId: 'minimal', sections: [{ key: 'summary' }, { key: 'experience', itemOrder: [first.key!, second.key!, third.key!] }, { key: 'education' }, { key: 'projects' }, { key: 'skills', hidden: true }] }; const create = async (name: string): Promise => { const response = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name, settings } }); expect(response.status()).toBe(200); return response.json(); }; const cv = await create('Åse — this CV only'); const sibling = await create('Åse — untouched sibling'); const read = async (id = cv.id): Promise => (await page.request.get(`${apiUrl}/cv/variants/${id}`)).json(); const saved = async () => { await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); }; const unchanged = async (expectedSource = source) => { expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(expectedSource); expect(await read(sibling.id)).toEqual(sibling); }; const experience = page.getByRole('group', { name: t.cvSectionExperience, exact: true }); const entry = (title: string) => experience.getByRole('group', { name: title, exact: true }); const openExperience = async () => { await page.getByRole('button', { name: sectionLabel('cvEditorExpandSection', t.cvSectionExperience), exact: true }).click(); await expect(entry(first.title!)).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 openExperience(); await saved(); await expect(entry(first.title!).getByText(t.cvEditorUsingSource, { exact: true }).first()).toBeVisible(); await expect(experience.getByText(t.cvEditorEntryScopeHelp, { exact: true })).toBeVisible(); await capture(entry(first.title!), info, 'desktop-source-entry'); await scan(page, info, 'desktop-source-entry', t.cvEditorEditingRegion); // The native UI writes only the current variant's lens over Career Profile. await entry(first.title!).getByLabel(t.cvEditorEntryTitle, { exact: true }).fill('CV-specific platform engineer'); const tailored = entry('CV-specific platform engineer'); await tailored.getByLabel(t.cvEditorEntrySubtitle, { exact: true }).fill('CV-specific employer — æøå'); await tailored.getByLabel(t.cvEditorBulletsHelp, { exact: true }).fill('CV-specific evidence.\nSecond tailored bullet.'); await expect.poll(async () => (await read()).settings.overrides[first.key!]).toMatchObject({ title: 'CV-specific platform engineer', subtitle: 'CV-specific employer — æøå', bullets: ['CV-specific evidence.', 'Second tailored bullet.'] }); await saved(); await unchanged(); await expect(tailored.getByText(t.cvEditorCustomized, { exact: true }).first()).toBeVisible(); const move = tailored.getByRole('button', { name: named('cvEditorMoveNamedEntryDown', 'CV-specific platform engineer'), exact: true }); await move.focus(); await page.keyboard.press('Enter'); await expect(move).toBeFocused(); const movedOrder = [second.key!, first.key!, third.key!]; await expect.poll(async () => (await read()).settings.sections.find(section => section.key === 'experience')?.itemOrder).toEqual(movedOrder); await saved(); await tailored.getByRole('button', { name: named('cvEditorHideEntry', 'CV-specific platform engineer'), exact: true }).click(); await expect.poll(async () => (await read()).settings.overrides[first.key!].hidden).toBe(true); await expect(tailored.getByLabel(t.cvEditorEntryTitle, { exact: true })).toBeEditable(); await expect(tailored.getByRole('button', { name: named('cvEditorShowEntry', 'CV-specific platform engineer'), exact: true })).toBeVisible(); await tailored.getByLabel(t.cvEditorEntrySubtitle, { exact: true }).fill('Edited while omitted — æøå'); await expect.poll(async () => (await read()).settings.overrides[first.key!].subtitle).toBe('Edited while omitted — æøå'); await saved(); for (const [field, label, value] of [ ['bullets', t.cvEditorBulletsHelp, first.bullets.join('\n')], ['title', t.cvEditorEntryTitle, first.title!], ['subtitle', t.cvEditorEntrySubtitle, first.subtitle ?? ''], ] as const) { const currentTitle = field === 'subtitle' ? first.title! : 'CV-specific platform engineer'; const before = await read(); const reset = entry(currentTitle).getByRole('button', { name: resetLabel(label), exact: true }); const resetRequest = page.waitForRequest(request => request.url() === `${apiUrl}/cv/variants/${cv.id}` && request.method() === 'PUT'); await reset.focus(); await page.keyboard.press('Enter'); await expect(entry(field === 'bullets' ? currentTitle : first.title!).getByLabel(label, { exact: true })).toBeFocused(); expect((await resetRequest).postDataJSON().settings.overrides[first.key!]).not.toHaveProperty(field); await expect.poll(async () => (await read()).settings.overrides[first.key!]?.[field] ?? null).toBeNull(); await saved(); const after = await read(); const remaining = { ...before.settings.overrides[first.key!] }; delete remaining[field]; const persisted = { ...after.settings.overrides[first.key!] }; delete persisted[field]; expect(persisted).toEqual(remaining); expect(after.settings.sections).toEqual(before.settings.sections); await expect(entry(field === 'bullets' ? currentTitle : first.title!).getByLabel(label, { exact: true })).toHaveValue(value); } await expect(entry(first.title!).getByText(t.cvEditorUsingSource, { exact: true }).first()).toBeVisible(); await expect(entry(first.title!).getByRole('button', { name: named('cvEditorShowEntry', first.title!), exact: true })).toBeVisible(); await unchanged(); await capture(entry(first.title!), info, 'desktop-reset-omitted-entry'); // An absent field inherits the latest source; a retained override remains local. await entry(first.title!).getByLabel(t.cvEditorEntryTitle, { exact: true }).fill('Retained CV title'); await expect.poll(async () => (await read()).settings.overrides[first.key!].title).toBe('Retained CV title'); await saved(); const changedProfile = { ...profile, jobs: profile.jobs.map(job => job.id === 'job-1' ? { ...job, title: 'Updated source title', company: 'Updated source employer', bullets: ['Updated source evidence — æøå.'] } : job) }; expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: changedProfile } })).status()).toBe(200); const changedSource = await (await page.request.get(`${apiUrl}/career/profile`)).json(); const beforeReload = await read(); await page.reload(); await page.getByRole('button', { name: sectionLabel('cvEditorExpandSection', t.cvSectionExperience), exact: true }).click(); const retained = entry('Retained CV title'); await expect(retained.getByLabel(t.cvEditorEntryTitle, { exact: true })).toHaveValue('Retained CV title'); await expect(retained.getByLabel(t.cvEditorEntrySubtitle, { exact: true })).toHaveValue('Updated source employer'); await expect(retained.getByLabel(t.cvEditorBulletsHelp, { exact: true })).toHaveValue('Updated source evidence — æøå.'); await expect(retained.getByRole('button', { name: named('cvEditorShowEntry', 'Retained CV title'), exact: true })).toBeVisible(); expect(await read()).toEqual(beforeReload); await unchanged(changedSource); await retained.getByRole('button', { name: named('cvEditorShowEntry', 'Retained CV title'), exact: true }).click(); await expect.poll(async () => (await read()).settings.overrides[first.key!].hidden).toBe(false); await saved(); await expect(retained.getByRole('button', { name: named('cvEditorHideEntry', 'Retained CV title'), exact: true })).toBeVisible(); // Actual HTML drag handlers persist the same itemOrder used by keyboard moves. await entry(second.title!).dragTo(retained, { sourcePosition: { x: 8, y: 24 }, targetPosition: { x: 8, y: 24 } }); await expect.poll(async () => (await read()).settings.sections.find(section => section.key === 'experience')?.itemOrder).toEqual([first.key!, second.key!, third.key!]); await saved(); await unchanged(changedSource); await capture(retained, info, 'desktop-customized-entry'); for (const width of [1440, 768, 320]) { await page.setViewportSize({ width, height: 900 }); await retained.getByLabel(t.cvEditorEntrySubtitle, { exact: true }).focus(); await expect(retained.getByLabel(t.cvEditorEntrySubtitle, { exact: true })).toBeFocused(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); await capture(retained, info, `scoped-entry-${width}`); await scan(page, info, `scoped-entry-${width}`, t.cvEditorEditingRegion); } await page.setViewportSize({ width: 1440, height: 900 }); // Existing document-name validation remains the validation owner; optional // source fields do not acquire invented required rules in the section UI. const cvName = page.getByLabel(t.cvEditorName, { exact: true }); await cvName.fill(''); await expect(page.getByRole('button', { name: t.cvEditorSaveNow, exact: true })).toBeDisabled(); await page.screenshot({ path: info.outputPath('name-validation.png'), animations: 'disabled' }); await cvName.fill(cv.name); await saved(); const addContent = page.getByRole('button', { name: t.cvEditorAddContent, exact: true }); await addContent.click(); const addDialog = page.getByRole('dialog'); await addDialog.getByText(t.cvEditorAddCustomHelp, { exact: true }).locator('..').getByRole('button', { name: t.cvEditorAdd, exact: true }).click(); await expect(addDialog).toHaveCount(0); await expect.poll(async () => (await read()).settings.customSections.length).toBe(1); await saved(); const customSetting = (await read()).settings.customSections[0]; const sectionName = page.getByLabel(sectionLabel('cvEditorSectionName', `custom:${customSetting.key}`), { exact: true }); await sectionName.fill('CV-only volunteering — æøå'); const custom = page.getByRole('group', { name: 'CV-only volunteering — æøå', exact: true }); await custom.getByRole('button', { name: sectionLabel('cvEditorExpandSection', 'CV-only volunteering — æøå'), exact: true }).click(); await expect(custom.getByText(t.cvEditorCvOnly, { exact: true })).toBeVisible(); await custom.getByRole('combobox', { name: new RegExp(`^${t.cvEditorContentFormat}`) }).click(); await page.getByRole('option', { name: t.cvEditorMultipleEntries, exact: true }).click(); const addEntry = custom.getByRole('button', { name: t.cvEditorAddEntry, exact: true }); const customEntry = (number: number) => custom.getByLabel(t.cvEditorEntry.replace('{number}', String(number)), { exact: true }); await addEntry.focus(); await page.keyboard.press('Enter'); await expect(customEntry(1)).toBeFocused(); await customEntry(1).fill('CV-only first contribution — æøå.'); await addEntry.click(); await expect(customEntry(2)).toBeFocused(); await customEntry(2).fill('CV-only second contribution.'); await expect.poll(async () => (await read()).settings.customSections[0].items).toEqual(['CV-only first contribution — æøå.', 'CV-only second contribution.']); await saved(); await capture(custom, info, 'custom-cv-only-populated'); for (const width of [768, 320]) { await page.setViewportSize({ width, height: 900 }); await customEntry(2).focus(); await expect(customEntry(2)).toBeFocused(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); await capture(custom, info, `custom-cv-only-${width}`); } const customMove = custom.getByRole('button', { name: t.cvEditorMoveEntryUp.replace('{number}', '2'), exact: true }); await customMove.focus(); await page.keyboard.press('Enter'); await expect(customEntry(1)).toBeFocused(); await expect.poll(async () => (await read()).settings.customSections[0].items).toEqual(['CV-only second contribution.', 'CV-only first contribution — æøå.']); await saved(); const deleteEntry = custom.getByRole('button', { name: t.cvEditorDeleteEntryNumber.replace('{number}', '1'), exact: true }); const beforeCancel = await read(); await deleteEntry.click(); await expect(page.getByRole('dialog').getByRole('button', { name: t.cancel, exact: true })).toBeFocused(); await page.getByRole('dialog').getByRole('button', { name: t.cancel, exact: true }).click(); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(deleteEntry).toBeFocused(); expect(await read()).toEqual(beforeCancel); await deleteEntry.click(); await capture(page.getByRole('dialog'), info, 'custom-entry-delete-confirmation'); await page.getByRole('dialog').getByRole('button', { name: t.cvEditorDeleteEntry, exact: true }).click(); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(customEntry(1)).toBeFocused(); await expect.poll(async () => (await read()).settings.customSections[0].items).toEqual(['CV-only first contribution — æøå.']); await saved(); await custom.getByRole('button', { name: t.cvEditorDeleteEntryNumber.replace('{number}', '1'), exact: true }).click(); await page.getByRole('dialog').getByRole('button', { name: t.cvEditorDeleteEntry, exact: true }).click(); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(addEntry).toBeFocused(); await expect.poll(async () => (await read()).settings.customSections[0].items).toEqual([]); await saved(); await capture(custom, info, 'custom-cv-only-empty'); await unchanged(changedSource); const removeSection = custom.getByRole('button', { name: sectionLabel('cvEditorRemoveSection', 'CV-only volunteering — æøå'), exact: true }); const beforeRemove = await read(); await removeSection.click(); await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(removeSection).toBeFocused(); expect(await read()).toEqual(beforeRemove); await removeSection.click(); await page.getByRole('dialog').getByRole('button', { name: t.cvEditorDeleteSection, exact: true }).click(); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(addContent).toBeFocused(); await expect.poll(async () => (await read()).settings.customSections).toEqual([]); await saved(); expect((await read()).settings.sections.some(section => section.key === `custom:${customSetting.key}`)).toBe(false); await unchanged(changedSource); // Existing history stores CV settings; it does not snapshot Career Profile. await page.setViewportSize({ width: 1440, height: 900 }); const final = await read(); 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 history.getByRole('button', { name: t.cvEditorRestoreTitle.replace('{version}', '1'), exact: true }).click(); await page.getByRole('dialog').getByRole('button', { name: t.cvEditorRestoreVersion, exact: true }).click(); await expect.poll(async () => (await read()).version).toBe(final.version + 1); expect((await read()).settings).toEqual(cv.settings); await unchanged(changedSource); await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click(); const inherited = entry('Updated source title'); await expect(inherited.getByLabel(t.cvEditorEntryTitle, { exact: true })).toHaveValue('Updated source title'); await expect(inherited.getByLabel(t.cvEditorEntrySubtitle, { exact: true })).toHaveValue('Updated source employer'); await expect(inherited.getByLabel(t.cvEditorBulletsHelp, { exact: true })).toHaveValue('Updated source evidence — æøå.'); await expect(inherited.getByText(t.cvEditorUsingSource, { exact: true }).first()).toBeVisible(); await unchanged(changedSource); } finally { await admin.dispose(); } }); }