import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; import { translations } from '../src/i18n/translations'; import { emptyStructuredCv } from '../src/profileCv'; import AxeBuilder from '@axe-core/playwright'; const api = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; async function login(request: APIRequestContext, email: string) { expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await (await request.get(`${api}/auth/me`)).json(); return { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string }; } async function owner(page: Page, admin: APIRequestContext, language: 'en' | 'nb' = 'en') { const adminHeaders = await login(admin, 'e2e@example.test'); const email = `pre-p10-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200); const headers = await login(page.request, email); const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact, fullName: 'Canonical Identity Marker', headline: 'Canonical Headline Marker', email: 'canonical-contact@example.test' }, summary: ['Canonical Summary Marker'], jobs: [{ id: 'pre-p10-job', title: 'Canonical Role Marker', company: 'Synthetic Employer', bullets: ['Canonical Detail Marker'] }] }; const before = await (await page.request.get(`${api}/career/profile`)).json(); expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: before.revision, profile } })).status()).toBe(200); expect((await page.request.put(`${api}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); await page.addInitScript(language => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', 'light'); }, language); await page.setViewportSize({ width: language === 'nb' ? 390 : 1440, height: 1000 }); return headers; } test.beforeEach(async ({ context }) => { 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(); }); }); for (const language of ['en', 'nb'] as const) { test(`[PRE-P10 A] ${language} blank CV survives rename, reload, template apply and empty export validation`, async ({ page, playwright }, info) => { test.setTimeout(180_000); const t = translations[language]; const admin = await playwright.request.newContext(); let headers: Record; try { headers = await owner(page, admin, language); } finally { await admin.dispose(); } const sourceBefore = await (await page.request.get(`${api}/career/profile`)).json(); await page.goto('/career/builder'); await page.getByRole('button', { name: t.cvDashboardNewCv, exact: true }).click(); const dialog = page.getByRole('dialog', { name: t.cvDashboardCreateTitle, exact: true }); await expect(dialog).toBeVisible(); await dialog.getByRole('radio', { name: t.cvDashboardStartBlank, exact: true }).check(); await expect(dialog.getByRole('radio', { name: t.cvDashboardStartBlank, exact: true })).toBeChecked(); const dialogAxe = await new AxeBuilder({ page }).include('[role="dialog"]').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); await info.attach('create-dialog-axe', { body: JSON.stringify({ violations: dialogAxe.violations, incomplete: dialogAxe.incomplete }), contentType: 'application/json' }); expect(dialogAxe.violations).toEqual([]); await dialog.getByLabel(t.cvEditorName, { exact: false }).fill('New blank CV'); const createdResponse = page.waitForResponse(response => response.url() === `${api}/cv/variants` && response.request().method() === 'POST'); await dialog.getByRole('button', { name: t.cvDashboardCreateCv, exact: true }).click(); const response = await createdResponse; expect(response.status()).toBe(200); const created = await response.json(); await expect(page).toHaveURL(new RegExp(`/career/builder/${created.id}$`)); const read = async () => (await page.request.get(`${api}/cv/variants/${created.id}`)).json(); const assertBlank = async () => { const saved = await read(); expect(saved.settings).toMatchObject({ sectionsExplicit: true, includeProfileContact: false, sections: [] }); const preview = await page.request.get(`${api}/cv/variants/${created.id}/preview`); expect(preview.status()).toBe(200); const rendered = await preview.json(); for (const marker of ['Canonical Identity Marker', 'Canonical Headline Marker', 'Canonical Summary Marker', 'Canonical Role Marker', 'canonical-contact@example.test']) { expect(rendered.html.includes(marker), `Blank CV must not inherit ${marker}`).toBe(false); } return saved; }; await expect(page.getByText(t.cvEditorEmptyBody, { exact: true })).toBeVisible(); await assertBlank(); await page.getByLabel(t.cvEditorName, { exact: true }).fill('Saved empty CV'); await expect.poll(async () => (await read()).name).toBe('Saved empty CV'); await page.reload(); await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue('Saved empty CV'); await expect(page.getByRole('checkbox', { name: t.cvEditorIncludeProfileContact, exact: true })).not.toBeChecked(); await assertBlank(); await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click(); await page.getByRole('button', { name: t.cvTemplateMinimalName, exact: true }).click(); await page.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click(); await expect.poll(async () => (await read()).settings.themeId).toBe('minimal'); await assertBlank(); await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click(); const editorAxe = await new AxeBuilder({ page }).include('#cv-studio-panel-1').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); await info.attach('blank-content-axe', { body: JSON.stringify({ violations: editorAxe.violations, incomplete: editorAxe.incomplete }), contentType: 'application/json' }); expect(editorAxe.violations).toEqual([]); const exportResponse = page.waitForResponse(response => new URL(response.url()).pathname === `/api/cv/variants/${created.id}/export-pdf` && response.request().method() === 'POST'); await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click(); const exported = await exportResponse; expect(exported.status()).toBe(400); expect(await exported.json()).toEqual({ code: 'cv_empty_content' }); await expect(page.getByText(t.cvEmptyContent, { exact: true })).toBeVisible(); const saved = await assertBlank(); const independent = await page.request.post(`${api}/cv/variants/${created.id}/export-pdf?expectedVersion=${saved.version}`, { headers }); expect(independent.status()).toBe(400); expect(await independent.json()).toEqual({ code: 'cv_empty_content' }); expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(sourceBefore); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); await info.attach('blank-roundtrip', { body: JSON.stringify({ language, created, saved, exportStatus: exported.status(), sourceUnchanged: true }), contentType: 'application/json' }); await page.screenshot({ path: info.outputPath('blank-roundtrip.png'), fullPage: true }); }); test(`[PRE-P10 A] ${language} document undo and both redo shortcuts preserve native text undo`, async ({ page, playwright }, info) => { test.setTimeout(180_000); const t = translations[language]; const admin = await playwright.request.newContext(); let headers: Record; try { headers = await owner(page, admin, language); } finally { await admin.dispose(); } const response = await page.request.post(`${api}/cv/variants`, { headers, data: { name: 'Keyboard undo runtime reproduction', settings: { themeId: 'minimal', sections: [{ key: 'summary' }, { key: 'experience' }] } } }); expect(response.status()).toBe(200); const cv = await response.json(); await page.goto(`/career/builder/${cv.id}`); const headline = page.getByLabel(t.cvEditorHeadlineOverride, { exact: true }); await expect(headline).toBeVisible(); const readOrder = async () => (await (await page.request.get(`${api}/cv/variants/${cv.id}`)).json()).settings.sections.slice(0, 2).map((section: { key: string }) => section.key); await page.getByRole('button', { name: t.cvEditorMoveSectionDown.replace('{section}', t.cvSectionSummary), exact: true }).click(); const undo = page.getByRole('button', { name: t.cvEditorUndo, exact: true }); const redo = page.getByRole('button', { name: t.cvEditorRedo, exact: true }); await expect(undo).toBeEnabled(); await expect.poll(readOrder).toEqual(['experience', 'summary']); for (const shortcut of ['Control+z', 'Control+y', 'Control+z', 'Control+Shift+z']) { const reverting = shortcut === 'Control+z'; await (reverting ? undo : redo).focus(); await page.keyboard.press(shortcut); await expect.poll(readOrder, { message: shortcut }).toEqual(reverting ? ['summary', 'experience'] : ['experience', 'summary']); } await headline.focus(); await headline.pressSequentially('Native input marker'); await expect(headline).toHaveValue('Native input marker'); await page.evaluate(() => { document.addEventListener('keydown', event => { if (event.ctrlKey && event.key.toLowerCase() === 'z') document.documentElement.dataset.nativeUndoPrevented = String(event.defaultPrevented); }); }); await page.keyboard.press('Control+z'); await expect(headline).not.toHaveValue('Native input marker'); await expect(headline).toBeFocused(); expect(await page.evaluate(() => document.documentElement.dataset.nativeUndoPrevented)).toBe('false'); expect(await readOrder()).toEqual(['experience', 'summary']); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); await info.attach('shortcut-roundtrip', { body: JSON.stringify({ language, finalOrder: await readOrder(), nativeInputValue: await headline.inputValue(), nativeUndoPrevented: false }), contentType: 'application/json' }); await page.screenshot({ path: info.outputPath('shortcut-roundtrip.png'), fullPage: true }); }); }