import { expect, test as base, type APIRequestContext, type APIResponse, type Page, type Response, type TestInfo } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; import { translations } from '../src/i18n/translations'; import type { CvTheme, CvVariant, CvVariantSummary } from '../src/cvBuilder'; import { emptyStructuredCv } from '../src/profileCv'; const apiUrl = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; type Owner = { id: string; headers: Record }; type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number }; const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 }; function settings(themeId: string) { return { themeId, showPhoto: false, showPageNumbers: false, showIcons: true, sections: [], overrides: {}, customSections: [] }; } async function json(response: APIResponse | Response, status = 200): Promise { expect(response.status(), `${response.url()} must return ${status}`).toBe(status); return response.json(); } async function login(api: APIRequestContext, email: string): Promise { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`)); const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(csrf).toBeDefined(); return { id: me.id, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } }; } async function createOwner(admin: APIRequestContext, owner: Owner) { const email = `p7-cv-library-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: owner.headers, data: { email, password, roles: [] } })).status()).toBe(200); return email; } const test = base.extend<{ owner: Owner }>({ owner: async ({ page, context, playwright }, provide, info) => { expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String)); const admin = await playwright.request.newContext(); try { const administrator = await login(admin, 'e2e@example.test'); const owner = await login(page.request, await createOwner(admin, administrator)); 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 provide(owner); } finally { await admin.dispose(); } }, }); async function seedSource(page: Page, owner: Owner) { const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact, fullName: 'Åse Nordlys — recorded owner-reviewed professional identity', headline: 'Recorded professional context', email: 'recorded-contact@example.test' }, jobs: [{ id: 'library-source-job', title: 'Recorded engineer', company: 'Nordlys AS', bullets: ['Recorded source evidence.'], skills: ['C#'] }], summary: ['Reviewed canonical summary, kept separate from CV-specific presentation.'] }; await json(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile, cvText: 'Exact owner-reviewed source — æøå\nRecorded work.\n' } })); return readSource(page.request); } async function readSource(api: APIRequestContext) { return { canonical: await json(await api.get(`${apiUrl}/career/profile`)), history: await json(await api.get(`${apiUrl}/career/profile/versions`)) }; } async function createVariant(api: APIRequestContext, owner: Owner, name: string, jobApplicationId?: number) { return json(await api.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: { name, jobApplicationId, settings: { ...settings('minimal'), language: 'nb-NO', overrides: { 'library-source-job': { title: 'CV-specific reviewed title', bullets: ['CV-specific reviewed evidence.'] } } } } })); } async function enter(page: Page, owner: Owner, sample: Sample = desktop) { expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); await page.addInitScript(({ language, theme }) => { if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language); if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.setViewportSize({ width: sample.width, height: sample.height }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('/career/builder?source=phase7-library#preserved'); const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') })); if (stored.language !== sample.language || stored.theme !== sample.theme) { await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.reload(); } await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en'); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme); await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1); await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); await expect(page.getByRole('heading', { level: 2, name: translations[sample.language].cvDashboardLibraryTitle, exact: true })).toBeVisible(); } function card(page: Page, name: string, language: Sample['language'] = 'en') { return page.getByRole('link', { name: translations[language].cvDashboardOpenNamed.replace('{name}', name), exact: true }); } function actionButton(page: Page, name: string, language: Sample['language'] = 'en') { return page.getByRole('button', { name: translations[language].cvDashboardActionsFor.replace('{name}', name), exact: true }); } async function openAction(page: Page, name: string, action: string, language: Sample['language'] = 'en') { await actionButton(page, name, language).focus(); await page.keyboard.press('Enter'); const menu = page.getByRole('menu'); await expect(menu).toBeVisible(); await menu.getByRole('menuitem', { name: action, exact: true }).focus(); await page.keyboard.press('Enter'); } async function openCreate(page: Page, language: Sample['language'] = 'en') { const t = translations[language]; await page.getByRole('button', { name: t.cvDashboardNewCv, exact: true }).click(); const dialog = page.getByRole('dialog', { name: t.cvDashboardCreateTitle, exact: true }); await expect(dialog).toBeVisible(); return dialog; } async function capture(page: Page, info: TestInfo, sample: Sample, state: string) { await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); }); const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight, rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), overflowElements: [...document.querySelectorAll('body *')] .filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden' && !element.closest('[hidden],[aria-hidden="true"]')) .map(element => { const rect = element.getBoundingClientRect(); return { tag: element.tagName, text: (element.textContent ?? '').slice(0, 100), left: rect.left, right: rect.right, width: rect.width }; }) .filter(element => element.width > 0 && (element.left < -1 || element.right > innerWidth + 1)).slice(0, 20), })); const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p7-cv-library-${sample.language}-${sample.theme}-${sample.width}-${state}`; await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, ariaSnapshot: await page.locator('body').ariaSnapshot(), axe: { scope: 'All enabled rules plus target-size; complete findings retained', violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, qualifications: ['Automated Chromium only; incomplete axe findings require triage and do not establish human screen-reader acceptance.', 'No physical touch/software keyboard, actual browser zoom, production font/photo, print/PDF fidelity, model/provider or production acceptance.'], }), contentType: 'application/json' }); await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); expect(geometry.rootOverflow).toBe(0); expect(axe.violations).toEqual([]); } test('[P7-04 PRES-C05 E08] failed list has an explicit keyboard retry with held progress, stable focus and actual recovery', async ({ page, owner }, info) => { const sourceBefore = await seedSource(page, owner); const existing = await createVariant(page.request, owner, 'Recorded recovery CV'); let reads = 0; let release!: () => void; const held = new Promise(resolve => { release = resolve; }); await page.route('**/api/cv/variants', async route => { if (route.request().method() !== 'GET') return route.continue(); reads++; if (reads === 1) return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled library read unavailable' }) }); if (reads === 2) await held; return route.continue(); }); await enter(page, owner); await expect(page.getByText('Controlled library read unavailable', { exact: true })).toBeVisible(); const retry = page.getByRole('button', { name: translations.en.retry, exact: true }); const content = page.getByRole('region', { name: translations.en.cvDashboardTitle, exact: true }); try { await retry.focus(); await page.keyboard.press('Enter'); await expect.poll(() => reads).toBe(2); await expect(content).toBeFocused(); await expect(page.getByRole('progressbar', { name: translations.en.loading, exact: true })).toBeVisible(); await expect(content.getByRole('status')).toHaveText(translations.en.loading); await expect(content.getByRole('status')).toHaveAttribute('aria-live', 'polite'); await page.keyboard.press('Enter'); expect(reads).toBe(2); } finally { release(); } await expect(card(page, existing.name)).toBeVisible(); await expect(page.getByText('Controlled library read unavailable', { exact: true })).toHaveCount(0); await expect(content).toBeFocused(); expect(reads).toBe(2); expect(await readSource(page.request)).toEqual(sourceBefore); await capture(page, info, desktop, 'list-recovered'); await info.attach('library-list-recovery', { body: JSON.stringify({ reads, qualification: 'First read is a controlled500; explicit retry is held before continuing the actual HTTP GET. No successful CV response is fabricated.' }), contentType: 'application/json' }); }); test('[P7-04 PRES-C05 C08 E08] locale-retained create draft locks during held failure and retries one identical snapshot', async ({ page, owner }, info) => { test.setTimeout(90_000); const sourceBefore = await seedSource(page, owner); const themes = await json(await page.request.get(`${apiUrl}/cv/themes`)); const selected = themes.find(theme => theme.id === 'minimal' && theme.available)!; expect(selected).toBeDefined(); let listReads = 0; let themeReads = 0; const payloads: unknown[] = []; let release!: () => void; const held = new Promise(resolve => { release = resolve; }); await page.route('**/api/cv/variants', async route => { if (route.request().method() === 'GET') { listReads++; return route.continue(); } if (route.request().method() !== 'POST') return route.continue(); payloads.push(route.request().postDataJSON()); if (payloads.length === 1) { await held; return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled create unavailable' }) }); } return route.continue(); }); await page.route('**/api/cv/themes', route => { themeReads++; return route.continue(); }); await enter(page, owner, { ...desktop, width: 360, height: 844 }); await expect(page.getByText(translations.en.cvDashboardEmptyTitle, { exact: true })).toBeVisible(); const name = 'Backendutvikler — Åse sin gjennomgåtte CV med en lang, beskrivende tittel'; let dialog = await openCreate(page); await dialog.getByLabel(translations.en.cvEditorName, { exact: false }).fill(name); await dialog.getByRole('button', { name: selected.name, exact: true }).focus(); await page.keyboard.press('Enter'); await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-pressed', 'true'); await dialog.getByRole('button', { name: translations.en.cancel, exact: true }).click(); const readsBeforeLocale = { listReads, themeReads }; const saved = page.waitForResponse(response => response.url() === `${apiUrl}/auth/preferences/language` && response.request().method() === 'PUT'); await page.locator('header').getByRole('button', { name: 'Norsk', exact: true }).click(); expect((await saved).status()).toBe(204); await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); dialog = await openCreate(page, 'nb'); await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toHaveValue(name); await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-pressed', 'true'); expect({ listReads, themeReads }).toEqual(readsBeforeLocale); try { await dialog.getByRole('button', { name: translations.nb.cvDashboardCreateCv, exact: true }).focus(); await page.keyboard.press('Enter'); await expect.poll(() => payloads.length).toBe(1); await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toHaveAttribute('required', ''); await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toBeDisabled(); await expect(dialog.getByRole('button', { name: translations.nb.cancel, exact: true })).toBeDisabled(); await expect(dialog.getByRole('button', { name: translations.nb.cvEditorAtsTemplates, exact: true })).toBeDisabled(); await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-disabled', 'true'); await page.keyboard.press('Enter'); await page.keyboard.press('Escape'); await expect(dialog).toBeVisible(); expect(payloads).toHaveLength(1); const content = dialog.getByRole('region', { name: translations.nb.cvDashboardCreateTitle, exact: true }); await page.keyboard.press('Tab'); await expect(content).toBeFocused(); const scrollBefore = await content.evaluate(element => ({ top: element.scrollTop, height: element.clientHeight, scrollHeight: element.scrollHeight })); if (scrollBefore.scrollHeight > scrollBefore.height + 1) { await page.keyboard.press(scrollBefore.top > 0 ? 'PageUp' : 'PageDown'); if (scrollBefore.top > 0) await expect.poll(() => content.evaluate(element => element.scrollTop)).toBeLessThan(scrollBefore.top); else await expect.poll(() => content.evaluate(element => element.scrollTop)).toBeGreaterThan(scrollBefore.top); } await info.attach('library-held-create-keyboard-scroll', { body: JSON.stringify({ scrollBefore, scrollAfter: await content.evaluate(element => element.scrollTop), qualification: 'Actual Tab reaches the named pending-dialog content; PageUp/PageDown is exercised when this viewport has vertical content overflow. Browser keyboard evidence does not establish physical touch/software-keyboard or human screen-reader acceptance.' }), contentType: 'application/json' }); await capture(page, info, { language: 'nb', theme: 'light', width: 360, height: 844 }, 'held-create'); } finally { release(); } await expect(dialog.getByText('Controlled create unavailable', { exact: true })).toBeVisible(); await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toHaveValue(name); await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-pressed', 'true'); const createdResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants` && response.request().method() === 'POST'); await dialog.getByRole('button', { name: translations.nb.cvDashboardCreateCv, exact: true }).click(); const created = await json(await createdResponse); await expect(page).toHaveURL(new RegExp(`/career/builder/${created.id}(?:[?#]|$)`)); expect(payloads).toHaveLength(2); expect(payloads[1]).toEqual(payloads[0]); expect(created).toMatchObject({ name, isPublic: false, version: 1, settings: { themeId: 'minimal' } }); expect(await json(await page.request.get(`${apiUrl}/cv/variants/${created.id}`))).toEqual(created); expect(await readSource(page.request)).toEqual(sourceBefore); await info.attach('library-create-recovery', { body: JSON.stringify({ readsBeforeLocale, listReads, themeReads, payloads, created, qualification: 'Locale is changed through the real header after cancelling the draft dialog. First create is a controlled500; only explicit retry reaches actual HTTP creation. No model/provider or source write.' }), contentType: 'application/json' }); }); test('[P7-04 PRES-C05 C07 C10 W07] keyboard rename duplicate and delete persist without changing source, settings or frozen packages', async ({ page, owner }, info) => { test.setTimeout(90_000); const sourceBefore = await seedSource(page, owner); const company = await json<{ id: number }>(await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded library fixture AS' } }), 201); const job = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId: company.id, jobTitle: 'Recorded library association', status: 'Saved' } }), 201); const original = await createVariant(page.request, owner, 'Recorded original CV', job.id); expect((await page.request.put(`${apiUrl}/jobapplications/${job.id}/cv`, { headers: owner.headers, data: { variantId: original.id } })).status()).toBe(200); const frozen = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications/${job.id}/submitted-packages`, { headers: owner.headers })); const frozenUrl = `${apiUrl}/jobapplications/${job.id}/submitted-packages/${frozen.id}`; const frozenBefore = await json(await page.request.get(frozenUrl)); await enter(page, owner); const renamedName = 'Recorded renamed CV — explicit owner review'; await openAction(page, original.name, translations.en.cvDashboardRename); const prompt = page.getByRole('dialog'); await expect(prompt.getByRole('textbox')).toBeFocused(); await prompt.getByRole('textbox').fill(renamedName); const renamedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${original.id}` && response.request().method() === 'PUT'); await prompt.getByRole('button', { name: translations.en.cvDashboardRename, exact: true }).click(); const renameResponse = await renamedResponse; const renamed = await json(renameResponse); expect(renameResponse.request().postDataJSON()).toMatchObject({ name: renamedName, source: 'manual', expectedVersion: original.version, settings: original.settings }); expect(renamed.settings).toEqual(original.settings); expect(renamed.version).toBe(original.version + 1); await expect(card(page, renamedName)).toBeVisible(); await expect(actionButton(page, renamedName)).toBeFocused(); let duplicateWrites = 0; let release!: () => void; const held = new Promise(resolve => { release = resolve; }); await page.route(`**/api/cv/variants/${original.id}/duplicate`, async route => { duplicateWrites++; await held; return route.continue(); }); const duplicatedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${original.id}/duplicate` && response.request().method() === 'POST'); try { await openAction(page, renamedName, translations.en.cvEditorDuplicate); await expect.poll(() => duplicateWrites).toBe(1); await expect(actionButton(page, renamedName)).toBeDisabled(); await page.keyboard.press('Enter'); expect(duplicateWrites).toBe(1); } finally { release(); } const copy = await json(await duplicatedResponse); expect(copy).toMatchObject({ name: `${renamedName} (copy)`, version: 1, isPublic: false, jobApplicationId: job.id }); expect(copy.id).not.toBe(original.id); expect(copy.publicSlug).not.toBe(original.publicSlug); expect(copy.settings).toEqual(renamed.settings); await expect(card(page, copy.name)).toBeVisible(); await openAction(page, copy.name, translations.en.cvDashboardDelete); const confirmation = page.getByRole('dialog'); await expect(confirmation.getByRole('button', { name: translations.en.cancel, exact: true })).toBeFocused(); await page.keyboard.press('Enter'); await expect(confirmation).toHaveCount(0); await expect(actionButton(page, copy.name)).toBeFocused(); expect((await page.request.get(`${apiUrl}/cv/variants/${copy.id}`)).status()).toBe(200); await openAction(page, copy.name, translations.en.cvDashboardDelete); const deletedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${copy.id}` && response.request().method() === 'DELETE'); await page.getByRole('dialog').getByRole('button', { name: translations.en.cvDashboardDeleteCv, exact: true }).focus(); await page.keyboard.press('Enter'); expect((await deletedResponse).status()).toBe(204); await expect(card(page, copy.name)).toHaveCount(0); await expect(page.getByRole('region', { name: translations.en.cvDashboardTitle, exact: true })).toBeFocused(); expect((await page.request.get(`${apiUrl}/cv/variants/${copy.id}`)).status()).toBe(404); expect(await json(await page.request.get(`${apiUrl}/cv/variants/${copy.id}/versions`))).toEqual([]); await page.reload(); await expect(card(page, renamedName)).toBeVisible(); await expect(card(page, copy.name)).toHaveCount(0); expect(await json(await page.request.get(`${apiUrl}/cv/variants/${original.id}`))).toEqual(renamed); expect(await readSource(page.request)).toEqual(sourceBefore); expect(await json(await page.request.get(frozenUrl))).toEqual(frozenBefore); await capture(page, info, desktop, 'crud-reloaded'); await info.attach('library-crud-readback', { body: JSON.stringify({ original, renamed, copy, duplicateWrites, frozenBefore, sourceUnchanged: true, frozenUnchanged: true, qualification: 'Actual HTTP rename/duplicate/delete and frozen DTO readback; held duplicate continues the real request. Frozen DTO equality is not archived PDF-byte or restore qualification.' }), contentType: 'application/json' }); }); test('[P7-04 PRES-C05 C10] concurrent rename receives ordinary409 and requires owner review before a new save', async ({ page, owner }, info) => { const sourceBefore = await seedSource(page, owner); const original = await createVariant(page.request, owner, 'Recorded conflict CV'); await enter(page, owner); const writes: unknown[] = []; let release!: () => void; const held = new Promise(resolve => { release = resolve; }); await page.route(`**/api/cv/variants/${original.id}`, async route => { if (route.request().method() !== 'PUT') return route.continue(); writes.push(route.request().postDataJSON()); if (writes.length === 1) await held; return route.continue(); }); await openAction(page, original.name, translations.en.cvDashboardRename); await page.getByRole('dialog').getByRole('textbox').fill('Unsaved rename that must not win'); const conflictedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${original.id}` && response.request().method() === 'PUT'); let intervening!: CvVariant; try { await page.getByRole('dialog').getByRole('button', { name: translations.en.cvDashboardRename, exact: true }).click(); await expect.poll(() => writes.length).toBe(1); intervening = await json(await page.request.put(`${apiUrl}/cv/variants/${original.id}`, { headers: owner.headers, data: { name: 'Recorded intervening owner change', settings: original.settings, source: 'manual', expectedVersion: original.version } })); } finally { release(); } const conflict = await conflictedResponse; expect(conflict.status()).toBe(409); expect(await conflict.json()).toMatchObject({ code: 'cv_revision_conflict' }); await expect(page.getByRole('alert').filter({ hasText: 'changed elsewhere' })).toBeVisible(); expect(writes).toHaveLength(1); expect(await json(await page.request.get(`${apiUrl}/cv/variants/${original.id}`))).toEqual(intervening); await page.reload(); await expect(card(page, intervening.name)).toBeVisible(); await openAction(page, intervening.name, translations.en.cvDashboardRename); await expect(page.getByRole('dialog').getByRole('textbox')).toHaveValue(intervening.name); await page.getByRole('dialog').getByRole('textbox').fill('Recorded rename after explicit reload and review'); const reviewedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${original.id}` && response.request().method() === 'PUT'); await page.getByRole('dialog').getByRole('button', { name: translations.en.cvDashboardRename, exact: true }).click(); const reviewed = await json(await reviewedResponse); expect(writes).toHaveLength(2); expect(writes[1]).toMatchObject({ expectedVersion: intervening.version, settings: intervening.settings, source: 'manual' }); expect(reviewed.version).toBe(intervening.version + 1); expect(reviewed.settings).toEqual(intervening.settings); expect(await readSource(page.request)).toEqual(sourceBefore); await info.attach('library-ordinary-rename-conflict', { body: JSON.stringify({ original, intervening, writes, reviewed, qualification: 'Both saves reach the actual server; browser PUT is held while an owner-authorized API save advances its revision. No expected-failure/skip or automatic retry.' }), contentType: 'application/json' }); }); test('[P7-04 PRES-C05 C08 C12 A02] free gallery, private ownership and revoked public links retain server admission boundaries', async ({ page, owner, playwright }, info) => { const sourceBefore = await seedSource(page, owner); const original = await createVariant(page.request, owner, 'Recorded owner-private CV'); const themes = await json(await page.request.get(`${apiUrl}/cv/themes`)); const locked = themes.find(theme => theme.requiresPro && !theme.available)!; expect(locked).toBeDefined(); expect((await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: { name: 'Forbidden premium CV', settings: settings(locked.id) } })).status()).toBe(403); await enter(page, owner); const dialog = await openCreate(page); await dialog.getByRole('button', { name: translations.en.cvEditorAllTemplates, exact: true }).click(); const premium = dialog.getByRole('button', { name: locked.name, exact: true }); await expect(premium).toHaveAttribute('aria-disabled', 'true'); await expect(premium).toHaveAttribute('tabindex', '-1'); const selectedBefore = await dialog.locator('[aria-pressed="true"][role="button"]').getAttribute('aria-label'); expect(selectedBefore).not.toBe(locked.name); await dialog.getByRole('button', { name: translations.en.cancel, exact: true }).click(); const foreign = await playwright.request.newContext(); const anonymous = await playwright.request.newContext(); const admin = await playwright.request.newContext(); try { const administrator = await login(admin, 'e2e@example.test'); const foreignOwner = await login(foreign, await createOwner(admin, administrator)); expect(await json(await foreign.get(`${apiUrl}/cv/variants`))).toEqual([]); const forbidden = [ await foreign.get(`${apiUrl}/cv/variants/${original.id}`), await foreign.put(`${apiUrl}/cv/variants/${original.id}`, { headers: foreignOwner.headers, data: { name: 'Forbidden rename', settings: original.settings, source: 'manual', expectedVersion: original.version } }), await foreign.post(`${apiUrl}/cv/variants/${original.id}/duplicate`, { headers: foreignOwner.headers, data: {} }), await foreign.put(`${apiUrl}/cv/variants/${original.id}/public`, { headers: foreignOwner.headers, data: { isPublic: true } }), await foreign.post(`${apiUrl}/cv/variants/${original.id}/export-pdf`, { headers: foreignOwner.headers, data: {} }), await foreign.delete(`${apiUrl}/cv/variants/${original.id}`, { headers: foreignOwner.headers }), ]; expect(forbidden.map(response => response.status())).toEqual([404, 404, 404, 404, 404, 404]); expect(await json(await foreign.get(`${apiUrl}/cv/variants/${original.id}/versions`))).toEqual([]); expect((await anonymous.get(`${apiUrl}/cv/variants/${original.id}`)).status()).toBe(401); expect((await anonymous.get(`${apiUrl}/public-cv/${original.publicSlug}`)).status()).toBe(404); const published = await json(await page.request.put(`${apiUrl}/cv/variants/${original.id}/public`, { headers: owner.headers, data: { isPublic: true } })); const publicResponse = await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}`); const publicDocument = await json<{ html: string; name: string }>(publicResponse); expect(publicResponse.headers()['x-robots-tag']).toBe('noindex, nofollow'); expect(publicDocument.html).toContain('CV-specific reviewed title'); await page.reload(); await expect(card(page, original.name).getByText(translations.en.cvEditorPublic, { exact: true })).toBeVisible(); await json(await page.request.put(`${apiUrl}/cv/variants/${original.id}/public`, { headers: owner.headers, data: { isPublic: false } })); expect((await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}`)).status()).toBe(404); expect((await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}/pdf`)).status()).toBe(404); const republished = await json(await page.request.put(`${apiUrl}/cv/variants/${original.id}/public`, { headers: owner.headers, data: { isPublic: true } })); expect(republished.publicSlug).not.toBe(published.publicSlug); expect((await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}`)).status()).toBe(404); expect((await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}/pdf`)).status()).toBe(404); expect((await anonymous.get(`${apiUrl}/public-cv/${republished.publicSlug}`)).status()).toBe(200); expect(await readSource(page.request)).toEqual(sourceBefore); await info.attach('library-server-admission', { body: JSON.stringify({ originalId: original.id, lockedTheme: locked.id, foreignStatuses: forbidden.map(response => response.status()), publishedSlug: published.publicSlug, republishedSlug: republished.publicSlug, qualification: 'Actual HTTP owner/entitlement, anonymous HTML and revoked-PDF admission only. No valid private/public PDF rendering or output-fidelity claim; public UI migration is a separate checkpoint.' }), contentType: 'application/json' }); } finally { await foreign.dispose(); await anonymous.dispose(); await admin.dispose(); } }); for (const status of [503, 504]) { test(`[P7-04 PRES-C05 C12 E08] private PDF${status} feedback does not retry or change the CV`, async ({ page, owner }, info) => { const sourceBefore = await seedSource(page, owner); const existing = await createVariant(page.request, owner, `Recorded PDF${status} CV`); let requests = 0; await page.route(`**/api/cv/variants/${existing.id}/export-pdf`, async route => { requests++; return route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled PDF unavailable' }) }); }); const sample: Sample = { language: 'nb', theme: 'dark', width: 390, height: 844 }; await enter(page, owner, sample); await openAction(page, existing.name, translations.nb.cvEditorDownloadPdf, 'nb'); await expect(page.getByRole('alert').filter({ hasText: status === 503 ? translations.nb.cvPdfBusy : translations.nb.cvPdfTimedOut })).toBeVisible(); expect(requests).toBe(1); expect(await json(await page.request.get(`${apiUrl}/cv/variants/${existing.id}`))).toEqual(existing); expect(await readSource(page.request)).toEqual(sourceBefore); await info.attach(`library-pdf-${status}-presentation`, { body: JSON.stringify({ requests, status, qualification: 'Controlled browser HTTP failure tests presentation/no automatic retry. Exporter is not invoked; this is not PDF output or actual admission qualification.' }), contentType: 'application/json' }); }); } test('[P7-04 PRES-C05 C08 E08] finite library and gallery width locale theme matrix keeps actions and real data reachable', async ({ page, owner }, info) => { test.setTimeout(180_000); const sourceBefore = await seedSource(page, owner); const longName = 'Backendutvikler — Åse Nordlys sin eiergjennomgåtte CV for dokumentert profesjonelt arbeid i norske organisasjoner'; const longUnbrokenName = 'EiergjennomgåttDokumentertProfesjonellErfaringOgKvalifikasjoner'.repeat(3); const originals = [await createVariant(page.request, owner, longName), await createVariant(page.request, owner, longUnbrokenName)]; const samples: Sample[] = [ { language: 'nb', theme: 'light', width: 320, height: 844 }, { language: 'en', theme: 'dark', width: 360, height: 844 }, { language: 'nb', theme: 'dark', width: 390, height: 844 }, { language: 'en', theme: 'light', width: 768, height: 1024 }, { language: 'nb', theme: 'light', width: 1024, height: 768 }, { language: 'en', theme: 'dark', width: 1280, height: 320 }, { language: 'nb', theme: 'dark', width: 1440, height: 900 }, { language: 'en', theme: 'light', width: 1920, height: 1080 }, ]; for (const sample of samples) { await enter(page, owner, sample); await expect(card(page, longName, sample.language)).toBeVisible(); await expect(card(page, longUnbrokenName, sample.language)).toBeVisible(); await actionButton(page, longName, sample.language).focus(); await page.keyboard.press('Enter'); await expect(page.getByRole('menu').getByRole('menuitem', { name: translations[sample.language].cvDashboardRename, exact: true })).toBeVisible(); await page.keyboard.press('Escape'); await expect(actionButton(page, longName, sample.language)).toBeFocused(); await page.getByRole('heading', { level: 2, name: translations[sample.language].cvDashboardLibraryTitle, exact: true }).scrollIntoViewIfNeeded(); await capture(page, info, sample, 'library'); const dialog = await openCreate(page, sample.language); await expect(dialog.getByLabel(translations[sample.language].cvEditorName, { exact: false })).toBeFocused(); await dialog.getByLabel(translations[sample.language].cvEditorName, { exact: false }).fill(longName); await dialog.getByRole('button', { name: translations[sample.language].cvEditorAllTemplates, exact: true }).click(); await capture(page, info, sample, 'gallery'); await dialog.getByRole('button', { name: translations[sample.language].cancel, exact: true }).focus(); await page.keyboard.press('Enter'); await expect(page.getByRole('button', { name: translations[sample.language].cvDashboardNewCv, exact: true })).toBeFocused(); } expect(await readSource(page.request)).toEqual(sourceBefore); for (const original of originals) expect(await json(await page.request.get(`${apiUrl}/cv/variants/${original.id}`))).toEqual(original); await info.attach('library-finite-responsive-matrix', { body: JSON.stringify({ samples, originals, qualification: 'Eight balanced samples, not the complete width x theme x locale cross-product. Library and create gallery are captured separately; no document-canvas scrolling, physical touch, actual zoom or human screen-reader acceptance.' }), contentType: 'application/json' }); });