feat(ui): migrate Career import review and version history

This commit is contained in:
cesnimda
2026-10-12 19:28:25 +02:00
parent ec23b650f7
commit 5d6e32358c
20 changed files with 10454 additions and 124 deletions
@@ -0,0 +1,532 @@
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<string, string> };
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<T>(response: APIResponse, status = 200): Promise<T> {
expect(response.status(), `${response.url()} must return ${status}`).toBe(status);
return response.json();
}
async function login(api: APIRequestContext, email: string): Promise<Owner> {
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<ReviewDiff>(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<CareerReadback>(await api.get(`${apiUrl}/career/profile`)),
history: await json<Version[]>(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<CareerReadback>(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: source(), cvText: raw } }));
if (twoVersions) await json<CareerReadback>(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<void>(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<CvVariant>(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<CvVariant>(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<unknown>(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<Run[]>(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<unknown>(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<CareerReadback>(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<Run[]>(await page.request.get(`${apiUrl}/profile-cv/runs`))).map(run => run.id)).toEqual([runA]);
expect((await json<Run[]>(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<void>(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<void>(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' });
});