import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; import { translations } from '../src/i18n/translations'; import { emptyStructuredCv } from '../src/profileCv'; const api = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; const themeNames = { modern: 'cvTemplateModernName', minimal: 'cvTemplateMinimalName', executive: 'cvTemplateExecutiveName', technical: 'cvTemplateTechnicalName', 'ats-classic': 'cvTemplateAtsClassicName', nordic: 'cvTemplateNordicName', elegant: 'cvTemplateElegantName', creative: 'cvTemplateCreativeName', code: 'cvTemplateCodeName', } as const; 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') { const adminHeaders = await login(admin, 'e2e@example.test'); const email = `template-selection-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: ['Premium'] } })).status()).toBe(200); const headers = await login(page.request, email); const initial = await (await page.request.get(`${api}/career/profile`)).json(); const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact, fullName: 'Owner Canonical Marker', headline: 'Canonical headline marker', email: 'synthetic@example.test' }, summary: ['Canonical summary marker'], skills: ['TypeScript', 'Documented evidence'] }; expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: initial.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 }); const source = await (await page.request.get(`${api}/career/profile`)).json(); return { headers, source }; } async function createCv(page: Page, headers: Record, showPhoto = false) { const response = await page.request.post(`${api}/cv/variants`, { headers, data: { name: 'Template selection CV', settings: { themeId: 'modern', sectionsExplicit: true, includeProfileContact: true, sections: [{ key: 'summary' }], headline: 'Authored CV headline marker', showPhoto, } } }); expect(response.status()).toBe(200); return response.json(); } 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 B] ${language} actual nine-theme gallery and candidate preview remain read-only until Apply`, async ({ page, playwright }, info) => { test.setTimeout(180_000); const t = translations[language]; const admin = await playwright.request.newContext(); let fixture: Awaited>; try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); } const cv = await createCv(page, fixture.headers); const cvUrl = `${api}/cv/variants/${cv.id}`; const read = async () => (await page.request.get(cvUrl)).json(); const requests: { path: string; status: number }[] = []; let writes = 0; page.on('response', response => { if (/\/cv\/themes\/[^/]+\/preview$/.test(new URL(response.url()).pathname)) requests.push({ path: new URL(response.url()).pathname, status: response.status() }); }); page.on('request', request => { if (request.url() === cvUrl && request.method() === 'PUT') writes++; }); await page.goto(`/career/builder/${cv.id}`); await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click(); await page.getByRole('button', { name: t.cvEditorAllTemplates, exact: true }).click(); for (const [id, key] of Object.entries(themeNames)) { const card = page.getByRole('button', { name: t[key], exact: true }); await card.scrollIntoViewIfNeeded(); await expect(card).toHaveAttribute('aria-disabled', 'false'); const frame = card.frameLocator('iframe'); await expect(frame.locator('.name')).toHaveText('Alex Morgan'); await expect(frame.locator('body')).not.toContainText('Owner Canonical Marker'); await expect.poll(() => requests.some(request => request.path === `/api/cv/themes/${id}/preview` && request.status === 200)).toBe(true); } const candidate = page.getByRole('button', { name: t.cvTemplateTechnicalName, exact: true }); await candidate.click(); const dialog = page.getByRole('dialog'); await expect(dialog.frameLocator('iframe').locator('.name')).toHaveText('Alex Morgan'); await expect(dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true })).toBeEnabled(); expect(await read()).toEqual(cv); expect(writes).toBe(0); await dialog.getByRole('button', { name: t.cancel, exact: true }).click(); expect(await read()).toEqual(cv); await candidate.click(); await expect(dialog.frameLocator('iframe').locator('.name')).toHaveText('Alex Morgan'); const applied = page.waitForResponse(response => response.url() === cvUrl && response.request().method() === 'PUT'); await dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click(); expect((await applied).status()).toBe(200); await expect.poll(async () => (await read()).settings.themeId).toBe('technical'); const saved = await read(); expect(saved.settings).toMatchObject({ designVersion: 2, themeId: 'technical', sectionsExplicit: true, includeProfileContact: true, sections: [{ key: 'summary' }], headline: 'Authored CV headline marker' }); expect(saved.version).toBe(cv.version + 1); if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click(); const live = page.frameLocator(`iframe[title="${t.cvEditorPreviewPage.replace('{page}', '1')}"]`); await expect(live.locator('.name')).toHaveText('Owner Canonical Marker'); await expect(live.locator('body')).toContainText('Authored CV headline marker'); await expect.poll(() => live.locator('body').evaluate(node => getComputedStyle(node, '::before').backgroundColor)).toBe('rgb(15, 76, 92)'); expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); await info.attach('actual-template-selection', { body: JSON.stringify({ language, samples: requests, savedSettings: saved.settings, writes }), contentType: 'application/json' }); await page.screenshot({ path: info.outputPath('template-selection.png'), fullPage: true }); }); test(`[PRE-P10 B] ${language} actual letter template Apply previews current authored text and preserves saved source`, async ({ page, playwright }, info) => { test.setTimeout(180_000); const t = translations[language]; const admin = await playwright.request.newContext(); let fixture: Awaited>; try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); } const companyResponse = await page.request.post(`${api}/companies`, { headers: fixture.headers, data: { name: 'Letter Selection Synthetic Company', source: 'User' } }); expect(companyResponse.status()).toBe(201); const company = await companyResponse.json(); const jobResponse = await page.request.post(`${api}/jobapplications`, { headers: fixture.headers, data: { companyId: company.id, jobTitle: 'Letter template fixture', status: 'Saved', description: 'Synthetic controlled fixture' } }); expect(jobResponse.status()).toBe(201); const job = await jobResponse.json(); const letterUrl = `${api}/jobapplications/${job.id}/cover-letter`; const original = await (await page.request.get(letterUrl)).json(); const documentSettings = { schemaVersion: 1, senderName: 'Letter sender marker', senderTitle: 'Reviewed title', senderEmail: 'letter@example.test', senderPhone: '+47 12345678', senderLocation: 'Oslo', recipient: 'Preserved recipient marker', subject: 'Preserved subject marker', date: '2026-10-27', themeId: 'modern', accentColor: '#2563eb', bodyFont: 'Arial, Helvetica, sans-serif', baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: 'letter', language: language === 'nb' ? 'nb-NO' : 'en' }; expect((await page.request.put(letterUrl, { headers: fixture.headers, data: { text: 'Saved letter body marker', source: 'manual', expectedVersion: original.revision, documentSettings } })).status()).toBe(200); const saved = await (await page.request.get(letterUrl)).json(); let writes = 0; const cvSamples: string[] = []; page.on('request', request => { if (request.method() === 'PUT' && request.url() === letterUrl) writes++; if (/\/cv\/themes\/[^/]+\/preview$/.test(new URL(request.url()).pathname)) cvSamples.push(request.url()); }); await page.goto(`/jobs/${job.id}?section=application&sub=letter#materials`); const body = page.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true }); await expect(body).toHaveValue('Saved letter body marker'); const draft = 'Current authored letter marker — Åse Østby'; await body.fill(draft); await page.getByRole('button', { name: t.letterChooseTemplate, exact: true }).click(); const dialog = page.getByRole('dialog'); await expect(dialog.getByRole('button', { name: t.letterTemplateApply, exact: true })).toBeEnabled(); await dialog.getByRole('combobox', { name: new RegExp(`^${t.letterTemplate}(?: |$)`) }).click(); await expect(page.getByRole('option')).toHaveCount(9); await page.getByRole('option', { name: 'Nordic', exact: true }).click(); await dialog.getByRole('button', { name: t.cancel, exact: true }).click(); await expect(body).toHaveValue(draft); expect(await (await page.request.get(letterUrl)).json()).toEqual(saved); await page.getByRole('button', { name: t.letterChooseTemplate, exact: true }).click(); await expect(dialog.getByRole('button', { name: t.letterTemplateApply, exact: true })).toBeEnabled(); await dialog.getByRole('combobox', { name: new RegExp(`^${t.letterTemplate}(?: |$)`) }).click(); await page.getByRole('option', { name: 'Nordic', exact: true }).click(); const previewResponse = page.waitForResponse(response => response.url() === `${letterUrl}/preview` && response.request().method() === 'POST' && response.request().postDataJSON()?.documentSettings?.themeId === 'nordic'); await dialog.getByRole('button', { name: t.letterTemplateApply, exact: true }).click(); await expect(body).toHaveValue(draft); if (language === 'nb') await page.getByRole('tab', { name: t.assetsLetterPreview, exact: true }).click(); const preview = await previewResponse; expect(preview.status()).toBe(200); const submitted = preview.request().postDataJSON(); expect(submitted.text).toBe(draft); expect(submitted.documentSettings).toMatchObject({ designVersion: 2, themeId: 'nordic', pageSize: 'letter', language: documentSettings.language, senderName: documentSettings.senderName, recipient: documentSettings.recipient, date: documentSettings.date, subject: documentSettings.subject }); const frame = page.frameLocator(`iframe[title="${t.letterDocumentPreview}"]`); await expect(frame.locator('.letter-theme-nordic')).toBeVisible(); await expect(frame.locator('body')).toContainText(draft); await expect(frame.locator('body')).toContainText(documentSettings.recipient); expect(await (await page.request.get(letterUrl)).json()).toEqual(saved); expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source); expect(writes).toBe(0); expect(cvSamples).toEqual([]); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); await info.attach('actual-letter-template-preview', { body: JSON.stringify({ language, submitted, unchangedSavedRevision: saved.revision, writes, cvSamples }), contentType: 'application/json' }); await page.screenshot({ path: info.outputPath('letter-template-selection.png'), fullPage: true }); }); test(`[PRE-P10 B] ${language} keyboard crop uses existing avatar storage and CV photo remains optional`, async ({ page, playwright }, info) => { test.setTimeout(180_000); const t = translations[language]; const admin = await playwright.request.newContext(); let fixture: Awaited>; try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); } const cv = await createCv(page, fixture.headers, true); await page.goto(`/career/builder/${cv.id}`); await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click(); await page.getByRole('link', { name: t.cvEditorManagePhoto, exact: true }).click(); await expect(page).toHaveURL(/\/career\?section=profile$/); await expect(page.getByRole('button', { name: t.profileChangeImage, exact: true })).toBeEnabled(); const png = await page.evaluate(() => { const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 384; const draw = canvas.getContext('2d')!; draw.fillStyle = '#0f766e'; draw.fillRect(0, 0, 256, 384); draw.fillStyle = '#f59e0b'; draw.fillRect(256, 0, 256, 384); return canvas.toDataURL('image/png').split(',')[1]; }); await page.locator('input[type=file]').nth(1).setInputFiles({ name: 'synthetic-nonhuman-color-blocks.png', mimeType: 'image/png', buffer: Buffer.from(png, 'base64') }); const crop = page.getByRole('dialog'); await expect(crop.getByAltText(t.cropDialogPreviewAlt, { exact: true })).toBeVisible(); const zoom = crop.getByRole('slider', { name: t.cropDialogZoom, exact: true }); await zoom.focus(); await zoom.press('End'); await expect(zoom).toHaveAttribute('aria-valuenow', '3'); const horizontal = crop.getByRole('slider', { name: t.cropDialogHorizontal, exact: true }); const vertical = crop.getByRole('slider', { name: t.cropDialogVertical, exact: true }); for (const slider of [horizontal, vertical]) { await expect(slider).toBeEnabled(); await slider.focus(); await slider.press('Home'); await expect.poll(async () => Number(await slider.getAttribute('aria-valuenow'))).toBeLessThan(0); await slider.press('End'); await expect(slider).toHaveAttribute('aria-valuenow', '0'); } const uploadResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/avatar' && response.request().method() === 'POST'); await crop.getByRole('button', { name: t.cropDialogSave, exact: true }).press('Enter'); expect((await uploadResponse).status()).toBe(200); await expect(crop).not.toBeVisible(); const avatar = (await (await page.request.get(`${api}/auth/me`)).json()).avatarImageDataUrl; expect(avatar).toBeTruthy(); await page.goto(`/career/builder/${cv.id}`); if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click(); const live = page.frameLocator(`iframe[title="${t.cvEditorPreviewPage.replace('{page}', '1')}"]`); await expect(live.locator('.photo img')).toBeVisible(); if (language === 'nb') await page.getByRole('button', { name: t.cvEditorEdit, exact: true }).click(); await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click(); await page.getByRole('switch', { name: t.cvEditorShowPhoto, exact: true }).uncheck(); await expect.poll(async () => (await (await page.request.get(`${api}/cv/variants/${cv.id}`)).json()).settings.showPhoto).toBe(false); if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click(); await expect(live.locator('.photo img')).toHaveCount(0); expect((await (await page.request.get(`${api}/auth/me`)).json()).avatarImageDataUrl).toBe(avatar); expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); await page.screenshot({ path: info.outputPath('optional-photo-after-keyboard-crop.png'), fullPage: true }); await info.attach('keyboard-crop', { body: JSON.stringify({ language, syntheticNonhumanFixture: true, keyboardZoom: 3, keyboardAxes: ['horizontal', 'vertical'], optionalCvPhoto: false, avatarRetained: true }), contentType: 'application/json' }); }); }