import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type TestInfo } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; import { realpathSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { translations } from '../src/i18n/translations'; import { emptyStructuredCv, type StructuredCvProfile } from '../src/profileCv'; const apiUrl = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; type Owner = { id: string; email: string; headers: Record }; type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number }; type CareerReadback = { profile: StructuredCvProfile; completeness: unknown; cvText: string | null }; type Version = { version: number; source: string; isCurrent: boolean; createdAtUtc: string }; type Run = { id: number; status: string; artifactFileName: string | null; operation: unknown }; type ReviewDiff = { runId: number; status: string; diff: { categories: { category: string; updated: { id: string; label: string; confidence: string; fieldChanges: { field: string; oldValue: string | null; newValue: string | null }[] }[] }[] } }; type CvVariant = { id: number; [key: string]: unknown }; 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; email: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`)); const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(csrf).toBeDefined(); expect(me.email).toBe(email); return { id: me.id, email: me.email, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } }; } async function createOwner(admin: APIRequestContext, owner: Owner) { const email = `p7-career-review-${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) => { const admin = await playwright.request.newContext(); try { const adminOwner = await login(admin, 'e2e@example.test'); const owner = await login(page.request, await createOwner(admin, adminOwner)); 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(); } }, }); function source(headline = 'Reviewed canonical professional context') { return { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact, fullName: 'Åse Nordlys — recorded professional identity with a deliberately long owner-reviewed name', headline, phone: '+47 900 00 001', email: 'recorded-professional-contact@example.test', location: 'Trondheim, Trøndelag, Norge — recorded remote and hybrid collaboration' }, summary: ['Reviewed canonical summary.'], jobs: [{ id: 'review-job-source', title: 'Senior engineer with recorded responsibility for professional delivery and reliable Scandinavian services', company: 'Nordlys programvare og dokumenterte tjenester AS', bullets: ['Original reviewed job evidence.'], skills: ['C#'] }], education: [{ id: 'review-education-source', qualification: 'Recorded computer science qualification', institution: 'Recorded university', details: ['Reviewed education detail.'] }], projects: [{ id: 'review-project-source', name: 'Reviewed project', bullets: ['Recorded project evidence.'], skills: ['React'] }], certifications: [{ id: 'review-certification-source', name: 'Recorded certification', issuer: 'Fixture Academy', details: ['Reviewed certification detail.'] }], languages: [{ name: 'Norwegian', level: 'A2', notes: 'Owner-reviewed language note.' }], skills: ['C#', 'Norsk'], awards: ['Recorded award'], publications: ['Recorded publication'], organisations: ['Recorded organisation'], references: ['Available by owner agreement'], otherSections: [{ title: 'Owner-reviewed custom information with a deliberately long descriptive heading', items: ['A custom fact retained during review.'] }], }; } const importedBullet = 'Imported recorded implementation detail with deliberately long professional context, retained only after explicit acceptance and never presented as a qualified model result.'; const importedContactEmail = 'pending-reviewed-contact@example.test'; function incoming() { const current = source(); return { version: '1', contact: { email: importedContactEmail }, jobs: [{ title: current.jobs[0].title, company: current.jobs[0].company, bullets: [importedBullet], skills: ['C#'] }], languages: [{ name: 'Norwegian', level: 'B1' }], }; } async function assertReachableLowContact(api: APIRequestContext, runId: number, currentEmail: string | null | undefined) { const review = await json(await api.get(`${apiUrl}/profile-cv/runs/${runId}/diff`)); expect(review).toMatchObject({ runId, status: 'pending_review' }); const contact = review.diff.categories.find(category => category.category === 'Contact'); expect(contact?.updated).toEqual([expect.objectContaining({ id: 'Contact', label: 'Contact details', confidence: 'Low', fieldChanges: [{ field: 'Email', oldValue: currentEmail, newValue: importedContactEmail }] })]); return review; } 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 seedSource(page: Page, owner: Owner, twoVersions = false) { const raw = 'Exact CURRENT original career source — æøå\nReviewed work and qualifications.\n'; await json(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: source(), cvText: raw } })); if (twoVersions) await json(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: source('Second reviewed canonical context'), cvText: raw } })); return readSource(page.request); } function seedPending(info: TestInfo, owner: Owner) { expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); const metadataRoot = info.config.metadata.isolatedDataRoot; if (typeof metadataRoot !== 'string') throw new Error('Review seeding requires explicit isolatedDataRoot metadata from a non-reused native server'); expect(path.isAbsolute(metadataRoot)).toBe(true); const dataRoot = realpathSync(metadataRoot); expect(path.dirname(dataRoot)).toBe(realpathSync(os.tmpdir())); expect(path.basename(dataRoot)).toMatch(/^jobtracker-e2e-[A-Za-z0-9][A-Za-z0-9_-]*$/); const helper = path.resolve('e2e/helpers/seed-career-review.py'); const result = spawnSync('rtk', ['proxy', process.env.PLAYWRIGHT_PYTHON_PATH || 'python', '-X', 'utf8', helper], { input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email, profile: incoming(), rawText: 'Synthetic pending extraction text — æøå', normalizedText: 'Synthetic pending normalized text; pending contact email; Norwegian B1.' }), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024, }); expect(result.error, 'guarded helper must start').toBeUndefined(); expect(result.status, result.stderr).toBe(0); const output = JSON.parse(result.stdout) as { runId: number }; expect(Object.keys(output)).toEqual(['runId']); expect(Number.isInteger(output.runId) && output.runId > 0).toBe(true); return output.runId; } async function enter(page: Page, owner: Owner, sample: Sample, section: 'profile' | 'import') { const initialNavigation = page.url() === 'about:blank'; expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); if (initialNavigation) { await page.addInitScript(({ language, theme }) => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', language); if (!localStorage.getItem('jobtracker.themeMode')) 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=${section}&source=phase7-review#preserved`); if (!initialNavigation) { 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); } 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-review'); expect(url.hash).toBe('#preserved'); } function runArticle(page: Page, language: Sample['language'], id: number) { return page.getByRole('article', { name: `${translations[language].careerImportUpload} ${id}`, exact: true }); } function lowContactCheckbox(article: Locator, language: Sample['language']) { const t = translations[language]; return article.getByRole('checkbox', { name: t.profileCvIncludeLowConfidence .replace('{category}', t.careerProfileContactSection).replace('{label}', t.careerImportContactDetails), exact: true }); } async function ready(article: Locator, language: Sample['language']) { const t = translations[language]; await expect(article).toBeVisible(); await expect(article.getByText(t.careerImportPendingReview, { exact: true })).toBeVisible(); await expect(lowContactCheckbox(article, language)).toBeVisible(); await expect(article.getByText(t.careerImportCurrentValue.replace('{value}', 'A2'), { exact: true })).toBeVisible(); await expect(article.getByText(t.careerImportIncomingValue.replace('{value}', 'B1'), { exact: true })).toBeVisible(); await expect(article.getByText(t.careerImportComparisonScope, { exact: true })).toBeVisible(); await expect(article.getByRole('button', { name: t.profileCvApplyChanges, exact: true })).toBeEnabled(); } async function openRaw(page: Page, language: Sample['language']) { const t = translations[language]; const disclosure = page.getByRole('button', { name: new RegExp(t.profileCvRawPanelTitle) }); if (await disclosure.getAttribute('aria-expanded') !== 'true') await disclosure.click(); const field = page.getByLabel(t.profileCvTextLabel, { exact: true }); await expect(field).toBeVisible(); return field; } async function openHistory(page: Page, language: Sample['language'], count: number) { const label = translations[language].profileCvVersionHistory.replace('{count}', String(count)); await expect(page.getByRole('heading', { level: 2, name: label, exact: true })).toBeVisible(); const section = page.getByRole('region', { name: label, exact: true }); if (count > 1) { const toggle = section.getByRole('button', { name: label, exact: true }); if (await toggle.getAttribute('aria-expanded') !== 'true') await toggle.click(); await expect(toggle).toHaveAttribute('aria-expanded', 'true'); } return section; } function sourceWrites(page: Page) { const writes: string[] = []; page.on('request', request => { const url = request.url(); if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && (url === `${apiUrl}/career/profile` || url.startsWith(`${apiUrl}/career/profile/versions/`) || url.startsWith(`${apiUrl}/profile-cv/`))) { writes.push(`${request.method()} ${url}`); } }); return writes; } 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-career-review-${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: { violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, qualification: 'Automated Chromium/seeded stored-review presentation only. All enabled axe results retained; incomplete results require triage. No actual extraction/model qualification, provider calls, physical touch/software keyboard, human screen reader, actual browser zoom, production font/photo or PDF/render-fidelity 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([]); } async function preservedDocuments(page: Page, owner: Owner, canonical: CareerReadback) { const company = await json<{ id: number }>(await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Import preservation fixture AS' } }), 201); const job = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId: company.id, jobTitle: 'Recorded import preservation', status: 'Saved' } }), 201); const selected = await json(await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: { name: 'Selected import preservation CV', jobApplicationId: job.id, settings: { themeId: 'minimal', overrides: { [canonical.profile.jobs[0].id!]: { title: 'CV-specific reviewed title', bullets: ['CV-specific reviewed evidence.'] } } } } })); const sibling = await json(await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: { name: 'Untouched import sibling CV', settings: { themeId: 'minimal' } } })); expect((await page.request.put(`${apiUrl}/jobapplications/${job.id}/cv`, { headers: owner.headers, data: { variantId: selected.id } })).status()).toBe(200); const frozen = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications/${job.id}/submitted-packages`, { headers: owner.headers })); const urls = [`${apiUrl}/cv/variants/${selected.id}`, `${apiUrl}/cv/variants/${selected.id}/versions`, `${apiUrl}/cv/variants/${sibling.id}`, `${apiUrl}/cv/variants/${sibling.id}/versions`, `${apiUrl}/jobapplications/${job.id}/submitted-packages/${frozen.id}`]; const before = await Promise.all(urls.map(async url => json(await page.request.get(url)))); return { urls, before }; } test('[P7-03 PRES-C01 C02 C03 C04 C05 W07] explicit select/deselect acceptance uses stable IDs and preserves raw, CV siblings and frozen snapshots', async ({ page, owner }, info) => { test.setTimeout(120_000); const before = await seedSource(page, owner); const documents = await preservedDocuments(page, owner, before.canonical); const firstRun = seedPending(info, owner); const firstDiff = await assertReachableLowContact(page.request, firstRun, before.canonical.profile.contact.email); expect(await readSource(page.request)).toEqual(before); const firstStored = (await json(await page.request.get(`${apiUrl}/profile-cv/runs`))).find(run => run.id === firstRun)!; expect(firstStored.artifactFileName).toBeNull(); expect(firstStored.operation).toBeNull(); const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 }; await enter(page, owner, sample, 'import'); const article = runArticle(page, 'en', firstRun); await ready(article, 'en'); await expect(await openRaw(page, 'en')).toHaveValue(before.canonical.cvText!); const contact = lowContactCheckbox(article, 'en'); await expect(contact).not.toBeChecked(); await contact.check(); await expect(contact).toBeChecked(); await contact.uncheck(); const firstResponse = page.waitForResponse(response => response.url() === `${apiUrl}/profile-cv/runs/${firstRun}/accept` && response.request().method() === 'POST'); await article.getByRole('button', { name: translations.en.profileCvApplyChanges, exact: true }).focus(); await page.keyboard.press('Enter'); const firstAccepted = await firstResponse; expect(firstAccepted.status()).toBe(200); expect(firstAccepted.request().postDataJSON()).toEqual({ acceptedLowConfidenceIds: [] }); await expect(article.getByText(translations.en.careerImportApplied, { exact: true })).toBeVisible(); await expect(article).toBeFocused(); const afterFirst = await readSource(page.request); expect(afterFirst.canonical.profile.contact.email).toBe(before.canonical.profile.contact.email); expect((await json<{ email: string }>(await page.request.get(`${apiUrl}/auth/me`))).email).toBe(owner.email); expect(afterFirst.canonical.profile.languages.find(language => language.name === 'Norwegian')?.level).toBe('B1'); expect(afterFirst.canonical.cvText).toBe(before.canonical.cvText); expect(afterFirst.history).toHaveLength(before.history.length + 1); expect(afterFirst.history[0].source).toBe('upload:accepted'); expect(afterFirst.canonical.profile.jobs[0].id).toBe(before.canonical.profile.jobs[0].id); for (const key of ['education', 'projects', 'certifications', 'otherSections', 'awards', 'publications', 'organisations', 'references'] as const) { expect(afterFirst.canonical.profile[key]).toEqual(before.canonical.profile[key]); } const secondRun = seedPending(info, owner); const secondDiff = await assertReachableLowContact(page.request, secondRun, afterFirst.canonical.profile.contact.email); await enter(page, owner, { language: 'nb', theme: 'dark', width: 360, height: 800 }, 'import'); const secondArticle = runArticle(page, 'nb', secondRun); await expect(secondArticle.getByText(translations.nb.careerImportPendingReview, { exact: true })).toBeVisible(); const selected = lowContactCheckbox(secondArticle, 'nb'); await expect(selected).not.toBeChecked(); await selected.check(); const secondResponse = page.waitForResponse(response => response.url() === `${apiUrl}/profile-cv/runs/${secondRun}/accept` && response.request().method() === 'POST'); await secondArticle.getByRole('button', { name: translations.nb.profileCvApplyChanges, exact: true }).click(); const secondAccepted = await secondResponse; expect(secondAccepted.status()).toBe(200); expect(secondAccepted.request().postDataJSON()).toEqual({ acceptedLowConfidenceIds: ['Contact'] }); await expect(secondArticle).toBeFocused(); const afterSecond = await readSource(page.request); expect(afterSecond.canonical.profile.contact.email).toBe(importedContactEmail); expect(afterSecond.canonical.profile.contact.fullName).toBe(before.canonical.profile.contact.fullName); expect((await json<{ email: string }>(await page.request.get(`${apiUrl}/auth/me`))).email).toBe(owner.email); expect(afterSecond.canonical.cvText).toBe(before.canonical.cvText); expect(afterSecond.history).toHaveLength(afterFirst.history.length + 1); expect((await page.request.post(`${apiUrl}/profile-cv/runs/${secondRun}/accept`, { headers: owner.headers, data: { acceptedLowConfidenceIds: ['Contact'] } })).status()).toBe(409); expect(await readSource(page.request)).toEqual(afterSecond); const afterDocuments = await Promise.all(documents.urls.map(async url => json(await page.request.get(url)))); expect(afterDocuments).toEqual(documents.before); await info.attach('native-offline-review-acceptance', { body: JSON.stringify({ firstRun, secondRun, firstStored, firstDiff, secondDiff, before, afterFirst, afterSecond, acceptedSelections: [firstAccepted.request().postDataJSON(), secondAccepted.request().postDataJSON()], documentsUnchanged: true, qualification: 'Synthetic pending extraction rows seeded in isolated SQLite; all accept/history/source/document reads are actual HTTP. Null incoming artifact is intentional. CURRENT original raw is preserved; no pending-run raw or real extraction qualification is claimed. No explicit preview/render/PDF endpoint or model/provider request.' }), contentType: 'application/json' }); }); test('[P7-03 PRES-C01 C02 C04] discarding stored review retains dirty structured and CURRENT raw drafts without a source write', async ({ page, owner }, info) => { const before = await seedSource(page, owner); const runId = seedPending(info, owner); await assertReachableLowContact(page.request, runId, before.canonical.profile.contact.email); const sample: Sample = { language: 'en', theme: 'dark', width: 390, height: 844 }; await enter(page, owner, sample, 'profile'); const writes = sourceWrites(page); const draftPhone = '+47 900 00 009'; await page.getByLabel(translations.en.profileCvContactPhone, { exact: true }).fill(draftPhone); await navigateSection(page, 'en', 'import'); const raw = await openRaw(page, 'en'); const draftRaw = 'Unsaved CURRENT raw draft — æøå\nKept when the stored extraction is discarded.'; await raw.fill(draftRaw); const article = runArticle(page, 'en', runId); await expect(lowContactCheckbox(article, 'en')).toBeVisible(); await expect(article.getByRole('button', { name: translations.en.profileCvApplyChanges, exact: true })).toBeDisabled(); const response = page.waitForResponse(result => result.url() === `${apiUrl}/profile-cv/runs/${runId}/discard` && result.request().method() === 'POST'); await article.getByRole('button', { name: translations.en.profileCvDiscard, exact: true }).click(); expect((await response).status()).toBe(204); await expect(article.getByText(translations.en.careerImportDiscarded, { exact: true })).toBeVisible(); await expect(article).toBeFocused(); await expect(raw).toHaveValue(draftRaw); await navigateSection(page, 'en', 'profile'); await expect(page.getByLabel(translations.en.profileCvContactPhone, { exact: true })).toHaveValue(draftPhone); expect(await readSource(page.request)).toEqual(before); expect(writes).toEqual([`POST ${apiUrl}/profile-cv/runs/${runId}/discard`]); expect((await page.request.post(`${apiUrl}/profile-cv/runs/${runId}/discard`, { headers: owner.headers })).status()).toBe(409); expect(await readSource(page.request)).toEqual(before); await info.attach('native-offline-review-discard-drafts', { body: JSON.stringify({ runId, writes, draftPhone, draftRaw, canonicalAndHistoryUnchanged: true, qualification: 'Discard is actual bodyless HTTP204; no profile save or browser reload occurred. Draft retention is session-local, not a persistence claim.' }), contentType: 'application/json' }); }); test('[P7-03 PRES-C02 C04 E09] stored reviews are owner scoped and foreign/anonymous review actions cannot mutate source', async ({ page, owner, playwright }, info) => { const beforeA = await seedSource(page, owner); const runA = seedPending(info, owner); await assertReachableLowContact(page.request, runA, beforeA.canonical.profile.contact.email); const admin = await playwright.request.newContext(); const other = await playwright.request.newContext(); const anonymous = await playwright.request.newContext(); try { const adminOwner = await login(admin, 'e2e@example.test'); const ownerB = await login(other, await createOwner(admin, adminOwner)); await json(await other.put(`${apiUrl}/career/profile`, { headers: ownerB.headers, data: { profile: source('Owner B reviewed context'), cvText: 'Owner B exact raw' } })); const beforeB = await readSource(other); const runB = seedPending(info, ownerB); await assertReachableLowContact(other, runB, beforeB.canonical.profile.contact.email); expect((await json(await page.request.get(`${apiUrl}/profile-cv/runs`))).map(run => run.id)).toEqual([runA]); expect((await json(await other.get(`${apiUrl}/profile-cv/runs`))).map(run => run.id)).toEqual([runB]); for (const [api, foreign, headers] of [[page.request, runB, owner.headers], [other, runA, ownerB.headers]] as const) { expect((await api.get(`${apiUrl}/profile-cv/runs/${foreign}/diff`)).status()).toBe(404); expect((await api.post(`${apiUrl}/profile-cv/runs/${foreign}/accept`, { headers, data: { acceptedLowConfidenceIds: ['Contact'] } })).status()).toBe(404); expect((await api.post(`${apiUrl}/profile-cv/runs/${foreign}/discard`, { headers })).status()).toBe(404); } expect((await anonymous.get(`${apiUrl}/profile-cv/runs`)).status()).toBe(401); expect((await anonymous.get(`${apiUrl}/profile-cv/runs/${runA}/diff`)).status()).toBe(401); expect((await anonymous.post(`${apiUrl}/profile-cv/runs/${runA}/accept`, { data: { acceptedLowConfidenceIds: [] } })).status()).toBe(401); expect((await anonymous.post(`${apiUrl}/profile-cv/runs/${runA}/discard`)).status()).toBe(401); expect(await readSource(page.request)).toEqual(beforeA); expect(await readSource(other)).toEqual(beforeB); await enter(page, owner, { language: 'nb', theme: 'light', width: 768, height: 1024 }, 'import'); await ready(runArticle(page, 'nb', runA), 'nb'); await expect(runArticle(page, 'nb', runB)).toHaveCount(0); await info.attach('native-offline-review-owner-contract', { body: JSON.stringify({ runA, runB, owners: [owner.id, ownerB.id], bidirectionalForeignReadAcceptDiscard404: true, anonymousReadAcceptDiscard401: true, canonicalAndHistoryUnchanged: true }), contentType: 'application/json' }); } finally { await Promise.all([admin.dispose(), other.dispose(), anonymous.dispose()]); } }); test('[P7-03 PRES-C01 C02] version Restore appends a new structured version and preserves CURRENT raw text', async ({ page, owner }, info) => { const before = await seedSource(page, owner, true); const sample: Sample = { language: 'nb', theme: 'dark', width: 360, height: 800 }; await enter(page, owner, sample, 'profile'); await expect(page.getByLabel(translations.nb.profileCvContactPhone, { exact: true })).toHaveValue('+47 900 00 001'); const section = await openHistory(page, 'nb', before.history.length); const restore = section.getByRole('button', { name: translations.nb.careerRestoreVersion.replace('{version}', '1'), exact: true }); await expect(restore).toBeEnabled(); const response = page.waitForResponse(result => result.url() === `${apiUrl}/career/profile/versions/1/restore` && result.request().method() === 'POST'); await restore.focus(); await page.keyboard.press('Enter'); expect((await response).status()).toBe(200); const after = await readSource(page.request); expect(after.history).toHaveLength(before.history.length + 1); expect(after.history[0]).toMatchObject({ version: 3, source: 'restore:v1', isCurrent: true }); expect(after.history.slice(1).map(version => ({ ...version, isCurrent: false }))).toEqual(before.history.map(version => ({ ...version, isCurrent: false }))); expect(after.canonical.profile.contact.headline).toBe('Reviewed canonical professional context'); expect(after.canonical.cvText).toBe(before.canonical.cvText); await expect(page.getByLabel(translations.nb.profileCvContactHeadline, { exact: true })).toHaveValue('Reviewed canonical professional context'); await page.reload(); await expect(page.getByLabel(translations.nb.profileCvContactHeadline, { exact: true })).toHaveValue('Reviewed canonical professional context'); await navigateSection(page, 'nb', 'import'); await expect(await openRaw(page, 'nb')).toHaveValue(before.canonical.cvText!); await info.attach('native-offline-review-restore', { body: JSON.stringify({ before, after, qualification: 'Existing unconditional structured Restore contract; current raw text is not historical raw and is retained. No ExpectedVersion or stale-write protection is claimed.' }), contentType: 'application/json' }); }); test('[P7-03 PRES-C02 C04 E08] comparison GET failure has scoped retry and held loading without source mutation', async ({ page, owner }, info) => { test.setTimeout(90_000); const before = await seedSource(page, owner); const runId = seedPending(info, owner); await assertReachableLowContact(page.request, runId, before.canonical.profile.contact.email); let reads = 0; let release!: () => void; const held = new Promise(resolve => { release = resolve; }); const endpoint = `${apiUrl}/profile-cv/runs/${runId}/diff`; await page.route(endpoint, async route => { expect(route.request().method()).toBe('GET'); reads += 1; if (reads === 1) await route.fulfill({ status: 500, contentType: 'text/plain', body: 'Controlled comparison read unavailable' }); else { await held; const response = await route.fetch(); await route.fulfill({ response }); } }); const sample: Sample = { language: 'en', theme: 'light', width: 320, height: 844 }; const writes = sourceWrites(page); try { await enter(page, owner, sample, 'import'); const article = runArticle(page, 'en', runId); await expect(article.getByRole('alert')).toContainText(translations.en.careerImportComparisonFailed); await expect(article.getByRole('alert')).toContainText('Controlled comparison read unavailable'); await expect(article.getByRole('button', { name: translations.en.profileCvApplyChanges, exact: true })).toBeDisabled(); expect(reads).toBe(1); await capture(page, info, sample, 'comparison-failure'); await article.getByRole('button', { name: translations.en.retry, exact: true }).focus(); await page.keyboard.press('Enter'); await expect.poll(() => reads).toBe(2); await expect(article).toBeFocused(); await expect(article.getByRole('progressbar', { name: translations.en.careerImportComparisonLoading, exact: true })).toBeVisible(); await expect(article.getByRole('button', { name: translations.en.profileCvApplyChanges, exact: true })).toBeDisabled(); expect(await readSource(page.request)).toEqual(before); expect(writes).toEqual([]); } finally { release(); } const article = runArticle(page, 'en', runId); await ready(article, 'en'); const contact = lowContactCheckbox(article, 'en'); await contact.check(); await expect(contact).toBeChecked(); await contact.uncheck(); expect(reads).toBe(2); expect(await readSource(page.request)).toEqual(before); expect(writes).toEqual([]); await info.attach('native-offline-review-comparison-recovery', { body: JSON.stringify({ runId, reads, writes, canonicalAndHistoryUnchanged: true, qualification: 'Only the first diff GET500 and second GET delay are controlled; successful comparison uses actual route.fetch HTTP and stored source.' }), contentType: 'application/json' }); }); 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 }, ]; test('[P7-03 PRES-C01 C02 C04 E08] history read retry and settled review/history presentation across eight widths', async ({ page, owner }, info) => { test.setTimeout(180_000); const before = await seedSource(page, owner, true); const runId = seedPending(info, owner); await assertReachableLowContact(page.request, runId, before.canonical.profile.contact.email); const endpoint = `${apiUrl}/career/profile/versions`; let reads = 0; let release!: () => void; const held = new Promise(resolve => { release = resolve; }); await page.route(endpoint, async route => { expect(route.request().method()).toBe('GET'); reads += 1; if (reads === 1) await route.fulfill({ status: 500, contentType: 'text/plain', body: 'Controlled version read unavailable' }); else { await held; const response = await route.fetch(); await route.fulfill({ response }); } }); const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 }; const writes = sourceWrites(page); try { await enter(page, owner, sample, 'profile'); await expect(page.getByLabel(translations.en.profileCvContactPhone, { exact: true })).toHaveValue('+47 900 00 001'); const failure = page.getByRole('main').getByRole('alert').filter({ hasText: translations.en.careerHistoryLoadFailed }); await expect(failure).toContainText('Controlled version read unavailable'); expect(reads).toBe(1); await failure.getByRole('button', { name: translations.en.retry, exact: true }).focus(); await page.keyboard.press('Enter'); await expect.poll(() => reads).toBe(2); await expect(page.getByRole('group', { name: translations.en.profileCvVersionHistory.replace('{count}', '0'), exact: true })).toBeFocused(); await expect(page.getByRole('progressbar', { name: translations.en.careerHistoryLoading, exact: true })).toBeVisible(); expect(await readSource(page.request)).toEqual(before); expect(writes).toEqual([]); } finally { release(); } const recovered = await openHistory(page, 'en', before.history.length); await expect(recovered.getByRole('button', { name: translations.en.careerRestoreVersion.replace('{version}', '1'), exact: true })).toBeEnabled(); expect(reads).toBe(2); await page.unroute(endpoint); for (const visual of matrix) { const t = translations[visual.language]; await enter(page, owner, visual, 'import'); const article = runArticle(page, visual.language, runId); await ready(article, visual.language); const raw = await openRaw(page, visual.language); await expect(raw).toHaveValue(before.canonical.cvText!); await expect(article.getByText(t.profileCvNoStoredArtifact, { exact: true })).toBeVisible(); await capture(page, info, visual, 'settled-stored-review'); await navigateSection(page, visual.language, 'profile'); await expect(page.getByLabel(t.profileCvContactHeadline, { exact: true })).toHaveValue('Second reviewed canonical context'); const history = await openHistory(page, visual.language, before.history.length); await expect(history.getByRole('button', { name: t.careerRestoreVersion.replace('{version}', '1'), exact: true })).toBeEnabled(); await capture(page, info, visual, 'settled-version-history'); expect(await readSource(page.request)).toEqual(before); } expect(writes).toEqual([]); await info.attach('native-offline-review-history-recovery-and-matrix', { body: JSON.stringify({ runId, initialControlledReads: 2, samples: matrix, writes, canonicalAndHistoryUnchanged: true, qualification: 'Eight paired automated viewport samples, not physical-device, software-keyboard, zoom or human screen-reader acceptance. Stored CURRENT raw is shown, not pending extraction raw.' }), contentType: 'application/json' }); });