import { expect, test as base, type APIRequestContext, type APIResponse, type Page, type TestInfo } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; import { translations } from '../src/i18n/translations'; import type { StructuredCvProfile } 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 }; type CareerReadback = { profile: StructuredCvProfile; completeness: unknown; cvText: string | null }; async function json(response: APIResponse, 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 } }; } const test = base.extend<{ owner: Owner }>({ owner: async ({ page, context, playwright }, provide) => { const admin = await playwright.request.newContext(); try { const adminOwner = await login(admin, 'e2e@example.test'); const email = `p7-career-editor-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: adminOwner.headers, data: { email, password, roles: [] } })).status()).toBe(200); const owner = 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 provide(owner); } finally { await admin.dispose(); } }, }); async function seed(page: Page, owner: Owner) { const profile = { version: '1', metadata: { fields: { 'contact.fullName': { confidence: null, sourcePage: null, method: 'manual', sourceSnippet: 'A long owner-reviewed source note about recorded work and qualifications, retained without adding a new claim.', reviewState: 'reviewed' } } }, contact: { fullName: 'Åse Nordlys — owner-reviewed professional identity with an intentionally long recorded name', headline: 'Experienced software engineer with recorded delivery, collaboration and professional responsibility across Norwegian organisations', email: 'owner-reviewed-professional-contact@example.test', phone: '+47 900 00 001', location: 'Trondheim, Trøndelag, Norge — remote and hybrid work', website: 'https://example.test/portfolio/recorded-professional-work', linkedIn: 'https://www.linkedin.com/in/reviewed-professional-identity', gitHub: 'https://github.com/reviewed-professional-identity', links: [{ label: 'Detailed portfolio of recorded work and owner-reviewed outcomes', url: 'https://example.test/recorded-work/long-reviewed-professional-source-reference' }] }, summary: ['Owner-reviewed professional summary with specific recorded delivery and collaboration.', 'Another factual summary line, kept separate.'], jobs: [{ id: 'editor-job-source', title: 'Senior software engineer with responsibility for recorded platform delivery and professional collaboration', company: 'Nordlys programvare og dokumenterte tjenester AS', location: 'Trondheim and remote collaboration across Norway', start: 'Jan 2020', end: 'Dec 2022', bullets: ['Recorded delivery with owner-reviewed implementation details.', 'Recorded collaboration and operational responsibility.'], skills: ['C#', 'React', 'Norsk'] }], education: [{ id: 'editor-education-source', qualification: 'Computer science and software engineering with a recorded specialisation in reliable services', qualificationLevel: 'Bachelor', institution: 'Recorded university department for computer science and software engineering', location: 'Trondheim, Norge', start: 'Aug 2016', end: 'Jun 2019', details: ['Recorded qualification and reviewed project work.'] }], projects: [{ id: 'editor-project-source', name: 'Recorded professional project', role: 'Developer', start: '2023-01', end: '2023-12', bullets: ['Recorded project evidence.'], skills: ['React'] }], certifications: [{ id: 'editor-certification-source', name: 'Recorded certification', issuer: 'Fixture Academy', date: 'Jun 2022', details: ['Reviewed certification detail.'] }], skills: ['C#', 'React', 'SQL', 'Norsk'], interests: ['Recorded community participation'], languages: [{ name: 'Norwegian Sign Language — recorded professional communication experience', level: 'Professional working proficiency with reviewed workplace communication', notes: 'An owner-reviewed language note retained with the source and kept separate from CV-specific presentation.' }], awards: ['Recorded award'], publications: ['Recorded publication'], organisations: ['Recorded organisation'], references: ['Available by owner agreement'], otherSections: [{ title: 'Owner-reviewed custom career information with a deliberately long descriptive section title', items: ['Recorded custom fact with relevant professional context.', 'Another recorded custom fact, preserved during unrelated contact editing.'] }], }; const raw = 'Exact original career source — æøå\nRecorded work and qualifications.\n'; await json(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile, cvText: raw } })); return { canonical: await json(await page.request.get(`${apiUrl}/career/profile`)), history: await json(await page.request.get(`${apiUrl}/career/profile/versions`)) }; } async function enter(page: Page, owner: Owner, sample: Sample) { 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 }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.setViewportSize({ width: sample.width, height: sample.height }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('/career?section=profile&source=phase7-editor#preserved'); 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.getByLabel(translations[sample.language].profileCvContactPhone, { exact: true })).toHaveValue('+47 900 00 001'); } async function navigateSection(page: Page, language: Sample['language'], section: 'profile' | 'import') { const t = translations[language]; const label = section === 'profile' ? t.careerWorkspaceProfileTab : t.careerWorkspaceImportTab; const selector = page.getByRole('combobox', { name: t.careerWorkspaceNavigation, exact: true }); if (await selector.isVisible()) { await selector.click(); await page.getByRole('option', { name: label, exact: true }).click(); } else await page.getByRole('tab', { name: label, exact: true }).click(); const url = new URL(page.url()); expect(url.searchParams.get('section')).toBe(section); expect(url.searchParams.get('source')).toBe('phase7-editor'); expect(url.hash).toBe('#preserved'); } function escaped(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } async function openRaw(page: Page, language: Sample['language']) { const field = page.getByLabel(translations[language].profileCvTextLabel, { exact: true }); const disclosure = page.getByRole('button', { name: new RegExp(escaped(translations[language].profileCvRawPanelTitle)) }); if (await disclosure.getAttribute('aria-expanded') !== 'true') await disclosure.click(); await expect(disclosure).toHaveAttribute('aria-expanded', 'true'); await expect(field).toBeVisible(); await field.scrollIntoViewIfNeeded(); await expect(field).toBeInViewport({ ratio: 0.5 }); await expect(disclosure).toHaveAttribute('aria-expanded', 'true'); return field; } async function settle(page: Page) { await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); }); } async function capture(page: Page, info: TestInfo, sample: Sample, state: string) { await settle(page); 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(); const style = getComputedStyle(element); return { tag: element.tagName.toLowerCase(), id: element.id || null, class: element.getAttribute('class'), text: (element.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120), left: rect.left, right: rect.right, width: rect.width, minWidth: style.minWidth, display: style.display, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth }; }) .filter(element => element.width > 0 && (element.right > innerWidth + 1 || element.left < -1)) .slice(0, 20), visibleFields: [...document.querySelectorAll('#career-profile-editor input, #career-profile-editor textarea, #career-cv-import input, #career-cv-import textarea')] .filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden' && element.getAttribute('aria-hidden') !== 'true') .map(element => { const rect = element.getBoundingClientRect(); return { name: element.getAttribute('aria-label') || (element as HTMLInputElement).labels?.[0]?.textContent, left: rect.left, right: rect.right, width: rect.width }; }), })); const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p7-career-editor-${sample.language}-${sample.theme}-${sample.width}-${state}`; await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, ariaSnapshot: await page.getByRole('main').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 evidence only; no human screen reader or physical touch/software keyboard', 'No actual browser zoom, production font/photo, PDF, 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(geometry.visibleFields.length).toBeGreaterThan(0); if (state === 'held-save-raw-import') expect(geometry.visibleFields.some(field => field.name === translations[sample.language].profileCvTextLabel)).toBe(true); expect(geometry.visibleFields.every(field => field.left >= 0 && field.right <= geometry.width && field.width > 0)).toBe(true); expect(axe.violations).toEqual([]); return axe; } test('[P7-03 PRES-C01 C02 E08] structured and raw drafts survive English to Bokmål without canonical refetch or write', async ({ page, owner }, info) => { test.setTimeout(90_000); const before = await seed(page, owner); const sample: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 }; let profileReads = 0; const readCompletions: Promise[] = []; await page.route('**/api/career/profile', async route => { if (route.request().method() !== 'GET') return route.continue(); profileReads++; const completed = (async () => { const response = await route.fetch(); await route.fulfill({ response }); })(); readCompletions.push(completed); await completed; }); const canonicalWrites: unknown[] = []; page.on('request', request => { if (request.url() === `${apiUrl}/career/profile` && request.method() === 'PUT') canonicalWrites.push(request.postDataJSON()); }); await enter(page, owner, sample); const draftPhone = '+47 900 00 099'; const draftRaw = 'Unsaved owner-reviewed raw draft — æøå\nThis text is not persisted yet.\n'; await page.getByLabel(translations.en.profileCvContactPhone, { exact: true }).fill(draftPhone); await navigateSection(page, 'en', 'import'); await (await openRaw(page, 'en')).fill(draftRaw); await navigateSection(page, 'en', 'profile'); await expect(page.getByLabel(translations.en.profileCvContactPhone, { exact: true })).toHaveValue(draftPhone); const readsBeforeLocale = profileReads; const preferenceSaved = 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 preferenceSaved).status()).toBe(204); await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); await expect(page.getByLabel(translations.nb.profileCvContactPhone, { exact: true })).toBeVisible(); await settle(page); await Promise.all(readCompletions); await settle(page); const observedPhone = await page.getByLabel(translations.nb.profileCvContactPhone, { exact: true }).inputValue(); await navigateSection(page, 'nb', 'import'); const observedRaw = await (await openRaw(page, 'nb')).inputValue(); const after = await json(await page.request.get(`${apiUrl}/career/profile`)); const historyAfter = await json(await page.request.get(`${apiUrl}/career/profile/versions`)); await info.attach('native-career-editor-locale-drafts', { body: JSON.stringify({ readsBeforeLocale, readsAfterLocale: profileReads, expected: { phone: draftPhone, raw: draftRaw }, observed: { phone: observedPhone, raw: observedRaw }, canonicalWrites, before, after, historyAfter, qualification: 'Preference PUT204 is real; canonical GETs are proxied to actual HTTP for completion tracking. No source response is fabricated.' }), contentType: 'application/json' }); await capture(page, info, { ...sample, language: 'nb' }, 'locale-retained-raw-draft'); expect(readsBeforeLocale).toBeGreaterThan(0); expect(profileReads).toBe(readsBeforeLocale); expect(observedPhone).toBe(draftPhone); expect(observedRaw).toBe(draftRaw); expect(canonicalWrites).toEqual([]); expect(after).toEqual(before.canonical); expect(historyAfter).toEqual(before.history); }); test('[P7-03 PRES-C01 C02 E08] held save locks edits and duplicate writes; failed save retains its snapshot for explicit identical-body retry', async ({ page, owner }, info) => { test.setTimeout(120_000); const before = await seed(page, owner); const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 }; const t = translations.en; const payloads: unknown[] = []; let release!: () => void; const held = new Promise(resolve => { release = resolve; }); await page.route('**/api/career/profile', async route => { if (route.request().method() !== 'PUT') 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 career save unavailable' }) }); } return route.continue(); }); await enter(page, owner, sample); const draftPhone = '+47 900 00 088'; const draftSummary = 'Owner-reviewed pending summary.'; const draftRaw = 'Pending owner raw source — æøå\nRetain this exact draft after failure.\n'; await page.getByLabel(t.profileCvContactPhone, { exact: true }).fill(draftPhone); const summary = page.getByLabel(t.profileCvStructuredSummary, { exact: true }); await summary.fill(draftSummary); await navigateSection(page, 'en', 'import'); await (await openRaw(page, 'en')).fill(draftRaw); await navigateSection(page, 'en', 'profile'); await summary.evaluate((element: HTMLTextAreaElement) => element.setSelectionRange(0, 5)); const save = page.getByRole('button', { name: new RegExp(`^(?:${escaped(t.profileSaveChanges)}|${escaped(t.saving)})$`) }); const firstResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT'); let pending!: { fields: Array<{ name: string | null; disabled: boolean }>; actions: Array<{ name: string; disabled: boolean }>; rawDisabled: boolean; saveDisabled: boolean; savingLabelCount: number; writeCount: number; summaryAfterAttempt: string }; try { await save.focus(); await page.keyboard.press('Enter'); await expect.poll(() => payloads.length).toBe(1); await settle(page); const editor = page.locator('#career-profile-editor'); const actionNames = [t.profileCvAddLink, t.profileCvStructuredAddLanguage, t.profileCvStructuredAddJob, t.profileCvStructuredAddEducation, t.profileCvStructuredAddOtherSection, t.profileCvStructuredRemove, t.profileCvDeleteCustomLink.replace('{index}', '1'), t.careerProfileRemoveJob.replace('{index}', '1'), t.careerProfileRemoveEducation.replace('{index}', '1'), t.careerProfileRemoveLanguage.replace('{index}', '1'), t.careerProfileRemoveOtherSection.replace('{index}', '1'), t.richTextBold, t.richTextItalic, t.richTextUnderline, t.richTextLink]; const fields = await editor.locator('input:not([type="file"]),textarea:not([aria-hidden="true"])').evaluateAll(elements => elements .filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden') .map(element => ({ name: element.getAttribute('aria-label') || (element as HTMLInputElement).labels?.[0]?.textContent || null, disabled: (element as HTMLInputElement).disabled }))); const actions = (await editor.getByRole('button').evaluateAll(elements => elements.map(element => ({ name: element.getAttribute('aria-label') || (element.textContent ?? '').replace(/\s+/g, ' ').trim(), disabled: (element as HTMLButtonElement).disabled, })))).filter(action => actionNames.includes(action.name)); const rawDisabled = await page.getByLabel(t.profileCvTextLabel, { exact: true }).isDisabled(); const saveDisabled = await save.isDisabled(); const savingLabelCount = await page.getByRole('button', { name: t.saving, exact: true }).count(); // A native disabled button ignores click(); an enabled formatting button would mutate // the held snapshot's draft. These attempts remain local to this disposable owner. await save.evaluate((element: HTMLButtonElement) => element.click()); await editor.getByRole('button', { name: t.richTextBold, exact: true }).first().evaluate((element: HTMLButtonElement) => element.click()); await settle(page); pending = { fields, actions, rawDisabled, saveDisabled, savingLabelCount, writeCount: payloads.length, summaryAfterAttempt: await summary.inputValue() }; await info.attach('native-career-editor-pending-lock', { body: JSON.stringify({ pending, heldBody: payloads[0] }), contentType: 'application/json' }); await capture(page, info, sample, 'held-save'); await navigateSection(page, 'en', 'import'); await expect(await openRaw(page, 'en')).toBeDisabled(); try { await capture(page, info, sample, 'held-save-raw-import'); } finally { await navigateSection(page, 'en', 'profile'); } } finally { release(); } expect((await firstResponse).status()).toBe(500); await expect(page.getByRole('button', { name: t.profileSaveChanges, exact: true })).toBeEnabled(); const failedPhone = await page.getByLabel(t.profileCvContactPhone, { exact: true }).inputValue(); const failedSummary = await summary.inputValue(); const failedRaw = await page.getByLabel(t.profileCvTextLabel, { exact: true }).inputValue(); const failureAlerts = await page.getByRole('main').getByRole('alert').allTextContents(); const afterFailure = await json(await page.request.get(`${apiUrl}/career/profile`)); const historyAfterFailure = await json(await page.request.get(`${apiUrl}/career/profile/versions`)); await capture(page, info, sample, 'failed-save-retained-draft'); const retryResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT'); await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus(); await page.keyboard.press('Enter'); expect((await retryResponse).status()).toBe(200); const afterRetry = await json(await page.request.get(`${apiUrl}/career/profile`)); const historyAfterRetry = await json(await page.request.get(`${apiUrl}/career/profile/versions`)); await info.attach('native-career-editor-save-retry', { body: JSON.stringify({ pending: pending!, payloads, failedDraft: { phone: failedPhone, summary: failedSummary, raw: failedRaw }, failureAlerts, before, afterFailure, historyAfterFailure, afterRetry, historyAfterRetry, qualification: 'First500 is controlled transport failure; retry200 and canonical/history readbacks use actual SQLite HTTP. No canonical stale-write protection is claimed.' }), contentType: 'application/json' }); expect(pending!.fields.length).toBeGreaterThan(20); expect(pending!.fields.every(field => field.disabled)).toBe(true); expect(pending!.actions.length).toBeGreaterThanOrEqual(26); expect(pending!.actions.every(action => action.disabled)).toBe(true); expect(pending!.rawDisabled).toBe(true); expect(pending!.saveDisabled).toBe(true); expect(pending!.savingLabelCount).toBe(1); expect(pending!.writeCount).toBe(1); expect(pending!.summaryAfterAttempt).toBe(draftSummary); expect(failedPhone).toBe(draftPhone); expect(failedSummary).toBe(draftSummary); expect(failedRaw).toBe(draftRaw); expect(failureAlerts.some(alert => alert.includes(t.profileUpdateFailed) && alert.includes('Controlled career save unavailable'))).toBe(true); expect(afterFailure).toEqual(before.canonical); expect(historyAfterFailure).toEqual(before.history); expect(payloads).toHaveLength(2); expect(payloads[1]).toEqual(payloads[0]); expect(afterRetry.profile.contact.phone).toBe(draftPhone); expect(afterRetry.profile.summary).toEqual([draftSummary]); expect(afterRetry.cvText).toBe(draftRaw); expect(historyAfterRetry).toHaveLength(before.history.length + 1); await page.reload(); await expect(page.getByLabel(t.profileCvContactPhone, { exact: true })).toHaveValue(draftPhone); await expect(page.getByLabel(t.profileCvStructuredSummary, { exact: true })).toHaveValue(draftSummary); }); test('[P7-03 PRES-C01 C02 E08] actual validation400 retains draft and source; corrected explicit save persists once', async ({ page, owner }, info) => { test.setTimeout(90_000); const before = await seed(page, owner); const sample: Sample = { language: 'nb', theme: 'dark', width: 390, height: 844 }; const t = translations.nb; const payloads: unknown[] = []; page.on('request', request => { if (request.url() === `${apiUrl}/career/profile` && request.method() === 'PUT') payloads.push(request.postDataJSON()); }); await enter(page, owner, sample); const website = page.getByLabel(t.profileCvContactWebsite, { exact: true }); const prefix = 'https://example.test/'; const overLimit = prefix + 'a'.repeat(501 - prefix.length); await website.fill(overLimit); const rejectedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT'); await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus(); await page.keyboard.press('Enter'); const rejected = await rejectedResponse; const rejectedBody = await rejected.text(); expect(rejected.status()).toBe(400); await expect(page.getByRole('button', { name: t.profileSaveChanges, exact: true })).toBeEnabled(); const retainedWebsite = await website.inputValue(); const failureAlerts = await page.getByRole('main').getByRole('alert').allTextContents(); const afterFailure = await json(await page.request.get(`${apiUrl}/career/profile`)); const historyAfterFailure = await json(await page.request.get(`${apiUrl}/career/profile/versions`)); await capture(page, info, sample, 'actual-validation400-retained-draft'); const corrected = 'https://example.test/recorded-work/corrected-reviewed-reference'; await website.fill(corrected); const acceptedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT'); await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus(); await page.keyboard.press('Enter'); expect((await acceptedResponse).status()).toBe(200); const afterSave = await json(await page.request.get(`${apiUrl}/career/profile`)); const historyAfterSave = await json(await page.request.get(`${apiUrl}/career/profile/versions`)); await info.attach('native-career-editor-real-validation', { body: JSON.stringify({ rejectedStatus: rejected.status(), rejectedBody, requestedLength: overLimit.length, retainedWebsite, failureAlerts, payloads, before, afterFailure, historyAfterFailure, afterSave, historyAfterSave, qualification: 'The501-character website is rejected by the actual existing500-character short-field limit before persistence. No transport fault substitutes for this validation response.' }), contentType: 'application/json' }); expect(rejectedBody).toContain('A contact field exceeds the allowed length.'); expect(retainedWebsite).toBe(overLimit); expect(failureAlerts.some(alert => alert.includes(t.profileUpdateFailed) && alert.includes('A contact field exceeds the allowed length.'))).toBe(true); expect(afterFailure).toEqual(before.canonical); expect(historyAfterFailure).toEqual(before.history); expect(payloads).toHaveLength(2); expect(afterSave.profile.contact.website).toBe(corrected); expect(afterSave.cvText).toBe(before.canonical.cvText); expect(historyAfterSave).toHaveLength(before.history.length + 1); await page.reload(); await expect(page.getByLabel(t.profileCvContactWebsite, { exact: true })).toHaveValue(corrected); }); const matrix: Sample[] = [ { language: 'nb', theme: 'dark', width: 320, height: 844 }, { language: 'en', theme: 'light', width: 360, height: 800 }, { language: 'nb', theme: 'light', width: 390, height: 844 }, { language: 'en', theme: 'dark', width: 768, height: 1024 }, { language: 'nb', theme: 'dark', width: 1024, height: 768 }, { language: 'en', theme: 'light', width: 1280, height: 320 }, { language: 'nb', theme: 'light', width: 1440, height: 900 }, { language: 'en', theme: 'dark', width: 1920, height: 1080 }, ]; for (const sample of matrix) { test(`[P7-03 PRES-C01 C02 E08] structured editor headings, contextual actions and geometry ${sample.language}/${sample.theme}/${sample.width}x${sample.height}`, async ({ page, owner }, info) => { test.setTimeout(90_000); const before = await seed(page, owner); await enter(page, owner, sample); const t = translations[sample.language]; const writes: string[] = []; page.on('request', request => { if (request.url().startsWith(apiUrl) && !['GET', 'HEAD', 'OPTIONS'].includes(request.method())) writes.push(request.method() + ' ' + request.url()); }); // Capture the existing rendered baseline even when a new semantic requirement // fails in RED. This retains actual geometry, axe results and pixels for review. const axe = await capture(page, info, sample, 'structured-editor'); await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1); await expect(page.getByRole('heading', { level: 2, name: t.profileCvStructuredEditor, exact: true })).toBeVisible(); for (const label of [t.careerProfileContactSection, t.profileCvStructuredLanguages, t.profileCvStructuredJobs, t.profileCvStructuredEducation, t.profileCvStructuredOtherSections]) { await expect(page.getByRole('heading', { level: 3, name: label, exact: true })).toBeVisible(); } for (const label of [t.careerProfileRemoveLanguage, t.careerProfileRemoveJob, t.careerProfileRemoveEducation, t.careerProfileRemoveOtherSection]) { const remove = page.getByRole('button', { name: label.replace('{index}', '1'), exact: true }); await expect(remove).toHaveCount(1); await remove.focus(); await expect(remove).toBeFocused(); } expect(axe.violations).toEqual([]); expect(await json(await page.request.get(`${apiUrl}/career/profile`))).toEqual(before.canonical); expect(await json(await page.request.get(`${apiUrl}/career/profile/versions`))).toEqual(before.history); expect(writes).toEqual([]); }); }