feat(ui): migrate Career import review and version history
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
Reference in New Issue
Block a user