fix(cv-qa): preserve reviewed mapping order and verify merge journeys
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
import { expect, test, type APIRequest, type APIRequestContext, type APIResponse, type BrowserContext, type Locator, type Page, type TestInfo } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { CvOutline, CvVariant } from '../src/cvBuilder';
|
||||
import type { CoverLetter, SubmittedPackage, SubmittedPackageDetail } from '../src/applicationWorkspace';
|
||||
import type { StructuredCvProfile } from '../src/profileCv';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
type Language = 'en' | 'nb';
|
||||
type Headers = Record<string, string>;
|
||||
type BrowserTools = { request: APIRequest };
|
||||
type Snapshot = { revision: number; profile: StructuredCvProfile };
|
||||
type Block = { id: string; text: string; page: number | null };
|
||||
type Review = { runId: number; status: string; revision: number; sourceBlocks: Block[]; groups: { category: string; fields: { id: string }[] }[] };
|
||||
type Run = { id: number; status: string; parserVersion: string; operation: { progressStage: string } };
|
||||
type Timings = Record<string, number>;
|
||||
type MappedValue = { name: string; value: string; evidence?: string; corrected?: boolean };
|
||||
type Scenario = { language: Language; mode: 'light' | 'dark'; width: number; file: string; kind: 'job' | 'education' | 'repeated'; theme: 'minimal' | 'technical'; package?: boolean };
|
||||
|
||||
async function json<T>(response: APIResponse, status = 200): Promise<T> {
|
||||
expect(response.status(), response.url()).toBe(status);
|
||||
return response.json();
|
||||
}
|
||||
async function login(api: APIRequestContext, email: string): Promise<Headers> {
|
||||
await json(await api.post(`${apiUrl}/auth/login`, { data: { email, password } }));
|
||||
const me = await json<{ sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
|
||||
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
|
||||
expect(csrf).toBeDefined();
|
||||
return { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext };
|
||||
}
|
||||
async function career(api: APIRequestContext) { return json<Snapshot>(await api.get(`${apiUrl}/career/profile`)); }
|
||||
async function versions(api: APIRequestContext) { return json<unknown[]>(await api.get(`${apiUrl}/career/profile/versions`)); }
|
||||
async function readVariant(api: APIRequestContext, id: number) { return json<CvVariant>(await api.get(`${apiUrl}/cv/variants/${id}`)); }
|
||||
const sha = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex').toUpperCase();
|
||||
const clean = (text: string) => text.replace(/\s+/g, ' ').trim();
|
||||
const numbered = (text: string, number: number) => text.replace('{number}', String(number));
|
||||
function panel(page: Page, runId: number) { return page.locator(`#cv-import-${runId}-review-panel`); }
|
||||
async function keyboardClick(page: Page, button: Locator) { await button.focus(); await page.keyboard.press('Enter'); }
|
||||
|
||||
async function owner(page: Page, context: BrowserContext, playwright: BrowserTools, sample: Scenario, info: TestInfo) {
|
||||
const admin = await playwright.request.newContext();
|
||||
const email = `cv-qa-${Date.now()}-${info.testId.replace(/[^a-z0-9]/gi, '').slice(-14)}@example.test`;
|
||||
try {
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
await json(await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: ['Premium'] } }));
|
||||
} finally { await admin.dispose(); }
|
||||
const headers = await login(page.request, email);
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await context.addInitScript(({ language, mode }) => {
|
||||
localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode);
|
||||
}, sample);
|
||||
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 page.setViewportSize({ width: sample.width, height: 900 });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
return { email, headers };
|
||||
}
|
||||
|
||||
async function upload(page: Page, playwright: BrowserTools, language: Language, fileName: string, info: TestInfo, timings: Timings, stageName: string) {
|
||||
const t = translations[language];
|
||||
const before = await career(page.request);
|
||||
const historyBefore = await versions(page.request);
|
||||
const existing = new Set((await json<Run[]>(await page.request.get(`${apiUrl}/profile-cv/runs`))).map(run => run.id));
|
||||
const fixture = path.resolve(process.env.CV_QA_FIXTURE_ROOT!, fileName);
|
||||
const fixtureHash = sha(await readFile(fixture));
|
||||
await page.goto('/career?section=import');
|
||||
await expect(page.getByRole('button', { name: t.profileUploadCv, exact: true })).toBeEnabled();
|
||||
const started = Date.now();
|
||||
const accepted = page.waitForResponse(response => response.url() === `${apiUrl}/profile-cv/import` && response.request().method() === 'POST');
|
||||
await page.locator('input[type=file][accept*=".pdf"]').setInputFiles(fixture);
|
||||
expect((await accepted).status()).toBe(202);
|
||||
const observed: { status: string; stage: string | null; elapsedMs: number }[] = [];
|
||||
let pending: Run | undefined;
|
||||
await expect.poll(async () => {
|
||||
pending = (await json<Run[]>(await page.request.get(`${apiUrl}/profile-cv/runs`))).find(run => !existing.has(run.id));
|
||||
if (pending) observed.push({ status: pending.status, stage: pending.operation?.progressStage ?? null, elapsedMs: Date.now() - started });
|
||||
return pending?.status;
|
||||
}, { timeout: 90_000, message: 'A real owned Docling run must reach persisted pending_review' }).toBe('pending_review');
|
||||
timings[`${stageName}.uploadToPendingReviewMs`] = Date.now() - started;
|
||||
expect(pending!.parserVersion).toBe('docling-v1');
|
||||
expect(pending!.operation.progressStage).toBe('cv_review_ready');
|
||||
expect(await career(page.request)).toEqual(before);
|
||||
expect(await versions(page.request)).toEqual(historyBefore);
|
||||
await page.reload();
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', language === 'nb' ? 'nb-NO' : 'en');
|
||||
const review = await json<Review>(await page.request.get(`${apiUrl}/profile-cv/runs/${pending!.id}/review`));
|
||||
expect(review.status).toBe('pending_review');
|
||||
expect(review.revision).toBe(before.revision);
|
||||
expect(review.sourceBlocks.length).toBeGreaterThan(0);
|
||||
await expect(panel(page, pending!.id).getByRole('button', { name: t.cvImportApplyReviewed, exact: true })).toBeDisabled();
|
||||
expect(await career(page.request)).toEqual(before);
|
||||
expect(await versions(page.request)).toEqual(historyBefore);
|
||||
const source = await page.request.get(`${apiUrl}/profile-cv/runs/${pending!.id}/source`);
|
||||
expect(source.status()).toBe(200);
|
||||
expect(source.headers()['cache-control']).toContain('no-store');
|
||||
expect(sha(await source.body())).toBe(fixtureHash);
|
||||
const stranger = await playwright.request.newContext();
|
||||
try {
|
||||
await login(stranger, 'e2e@example.test');
|
||||
for (const suffix of ['source', 'review']) expect((await stranger.get(`${apiUrl}/profile-cv/runs/${pending!.id}/${suffix}`)).status()).toBe(404);
|
||||
} finally { await stranger.dispose(); }
|
||||
await info.attach(`${stageName}-persisted-stages`, { body: JSON.stringify({ runId: pending!.id, parserVersion: pending!.parserVersion, observed, fixtureHash,
|
||||
boundary: 'Only sampled persisted stages are claimed; intermediate stages can complete between polls. Real local Docling, no extraction/model mocks.' }), contentType: 'application/json' });
|
||||
return { runId: pending!.id, review, before, fixtureHash };
|
||||
}
|
||||
|
||||
async function rejectOriginal(page: Page, runId: number) {
|
||||
// Mapping editor selectors have cv-mapping IDs, so this addresses only extracted decisions.
|
||||
for (const select of await panel(page, runId).locator('select[id^="cv-import-"]').all()) await select.selectOption('keep');
|
||||
}
|
||||
async function approveExtracted(page: Page, runId: number, language: Language) {
|
||||
const buttons = panel(page, runId).getByRole('button', { name: translations[language].cvImportUseGroup, exact: true });
|
||||
expect(await buttons.count()).toBeGreaterThanOrEqual(3);
|
||||
for (const button of await buttons.all()) await button.click();
|
||||
}
|
||||
function evidence(review: Review, literal: string) {
|
||||
const block = review.sourceBlocks.find(item => clean(item.text).includes(clean(literal)));
|
||||
expect(block, `Original source block for literal ${JSON.stringify(literal)}`).toBeDefined();
|
||||
return block!;
|
||||
}
|
||||
async function author(page: Page, runId: number, review: Review, language: Language, category: 'jobs' | 'education', fields: MappedValue[], target: number | null = null) {
|
||||
const t = translations[language];
|
||||
const reviewPanel = panel(page, runId);
|
||||
await keyboardClick(page, reviewPanel.getByRole('button', { name: t.cvImportMapMissing, exact: true }));
|
||||
const mapping = reviewPanel.getByRole('group', { name: numbered(t.cvImportMappingRecord, 1), exact: true });
|
||||
await mapping.getByRole('combobox', { name: t.cvImportMappingCategory, exact: true }).selectOption(category);
|
||||
await mapping.getByRole('combobox', { name: t.cvImportMappingTarget, exact: true }).selectOption(target === null ? '' : String(target));
|
||||
const selected: { name: string; value: string; block: Block; corrected: boolean }[] = [];
|
||||
for (const [index, field] of fields.entries()) {
|
||||
const number = index + 1;
|
||||
if (index >= 2) await mapping.getByRole('button', { name: t.cvImportMappingAddField, exact: true }).click();
|
||||
await mapping.getByRole('combobox', { name: numbered(t.cvImportMappingField, number), exact: true }).selectOption(field.name);
|
||||
const block = evidence(review, field.evidence ?? field.value);
|
||||
const value = mapping.getByRole('textbox', { name: numbered(t.cvImportMappingValue, number), exact: true });
|
||||
await mapping.getByRole('combobox', { name: numbered(t.cvImportMappingSource, number), exact: true }).selectOption(block.id);
|
||||
await expect(value).toHaveValue(block.text);
|
||||
await value.fill(field.value);
|
||||
if (field.corrected) await mapping.getByRole('checkbox', { name: t.cvImportMappingCorrection, exact: true }).nth(index).check();
|
||||
selected.push({ name: field.name, value: field.value, block, corrected: field.corrected ?? false });
|
||||
}
|
||||
await keyboardClick(page, mapping.getByRole('button', { name: t.cvImportMappingConfirm, exact: true }));
|
||||
await expect(mapping.getByText(t.cvImportMappingConfirmed, { exact: true })).toBeVisible();
|
||||
return { mapping, selected };
|
||||
}
|
||||
|
||||
async function accessibility(page: Page, runId: number, info: TestInfo, name: string) {
|
||||
const result = await new AxeBuilder({ page }).include(`#cv-import-${runId}-review-panel`).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
await info.attach(`${name}-axe`, { body: JSON.stringify({ scope: `#cv-import-${runId}-review-panel`, violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' });
|
||||
expect(result.violations).toEqual([]);
|
||||
await page.screenshot({ path: info.outputPath(`${name}.png`), fullPage: true, animations: 'disabled' });
|
||||
}
|
||||
|
||||
async function apply(page: Page, headers: Headers, runId: number, language: Language, before: Snapshot, timings: Timings, name: string) {
|
||||
const endpoint = `${apiUrl}/profile-cv/runs/${runId}/apply-reviewed`;
|
||||
const started = Date.now();
|
||||
const response = page.waitForResponse(result => result.url() === endpoint && result.request().method() === 'POST');
|
||||
await keyboardClick(page, panel(page, runId).getByRole('button', { name: translations[language].cvImportApplyReviewed, exact: true }));
|
||||
const applied = await response;
|
||||
expect(applied.status()).toBe(200);
|
||||
await expect(page.locator(`#career-import-run-${runId}`).getByText(translations[language].cvImportSaved, { exact: true })).toBeVisible();
|
||||
await expect.poll(async () => (await career(page.request)).revision).toBe(before.revision + 1);
|
||||
const snapshot = await career(page.request);
|
||||
timings[`${name}.applyToDurableReadbackMs`] = Date.now() - started;
|
||||
const history = await versions(page.request);
|
||||
const replay = await page.request.post(endpoint, { headers, data: applied.request().postDataJSON() });
|
||||
expect(replay.status()).toBe(200);
|
||||
expect(await career(page.request)).toEqual(snapshot);
|
||||
expect(await versions(page.request)).toEqual(history);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function createVariant(api: APIRequestContext, headers: Headers, name: string, theme = 'minimal') {
|
||||
return json<CvVariant>(await api.post(`${apiUrl}/cv/variants`, { headers, data: { name, settings: { themeId: theme } } }));
|
||||
}
|
||||
async function editAndExport(page: Page, headers: Headers, sample: Scenario, sibling: CvVariant, info: TestInfo, timings: Timings) {
|
||||
const t = translations[sample.language];
|
||||
const source = await career(page.request);
|
||||
const sectionKey = sample.kind === 'education' ? 'education' : 'experience';
|
||||
const sectionName = sample.kind === 'education' ? t.cvSectionEducation : t.cvSectionExperience;
|
||||
const outline = await json<CvOutline>(await page.request.get(`${apiUrl}/cv/outline`));
|
||||
const entries = outline.sections.find(section => section.key === sectionKey)!.entries;
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
const first = entries[0];
|
||||
const canonical = sample.kind === 'education' ? source.profile.education[0] : source.profile.jobs[0];
|
||||
expect(canonical.id).toBeTruthy();
|
||||
expect(first.key).toBe(canonical.id);
|
||||
const cv = await json<CvVariant>(await page.request.post(`${apiUrl}/cv/variants`, { headers, data: {
|
||||
name: `QA ${sample.language} ${sample.kind} CV`, settings: { themeId: sample.theme, sections: [{ key: sectionKey, itemOrder: entries.map(entry => entry.key!) }] },
|
||||
} }));
|
||||
const started = Date.now();
|
||||
await page.goto(`/career/builder/${cv.id}`);
|
||||
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(cv.name);
|
||||
await page.getByRole('button', { name: t.cvEditorExpandSection.replace('{section}', sectionName), exact: true }).click();
|
||||
const section = page.getByRole('group', { name: sectionName, exact: true });
|
||||
const entry = section.getByRole('group', { name: first.title!, exact: true }).first();
|
||||
const tailoredTitle = `CV-only ${first.title}`;
|
||||
await entry.getByLabel(t.cvEditorEntryTitle, { exact: true }).fill(tailoredTitle);
|
||||
await expect.poll(async () => (await readVariant(page.request, cv.id)).settings.overrides[first.key!]?.title).toBe(tailoredTitle);
|
||||
await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible();
|
||||
expect((await readVariant(page.request, cv.id)).version).toBeGreaterThan(cv.version);
|
||||
expect(await career(page.request)).toEqual(source);
|
||||
expect(await readVariant(page.request, sibling.id)).toEqual(sibling);
|
||||
if (sample.width < 900) await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
|
||||
const title = t.cvEditorPreviewPage.replace('{page}', '1');
|
||||
await expect(page.getByTitle(title, { exact: true })).toBeVisible();
|
||||
await expect(page.frameLocator(`iframe[title=${JSON.stringify(title)}]`).locator('body')).toContainText(tailoredTitle);
|
||||
timings.editorOpenEditToPreviewMs = Date.now() - started;
|
||||
await page.screenshot({ path: info.outputPath('tailored-preview.png'), fullPage: true, animations: 'disabled' });
|
||||
const exportStarted = Date.now();
|
||||
const download = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click();
|
||||
const pdf = await download;
|
||||
expect(pdf.suggestedFilename()).toMatch(/\.pdf$/);
|
||||
const pdfPath = info.outputPath(`cv-qa-${sample.language}-${sample.kind}-${sample.theme}.pdf`);
|
||||
await pdf.saveAs(pdfPath);
|
||||
expect(await pdf.failure()).toBeNull();
|
||||
const bytes = await readFile(pdfPath);
|
||||
expect(bytes.subarray(0, 5).toString()).toBe('%PDF-');
|
||||
timings.pdfExportToSavedBytesMs = Date.now() - exportStarted;
|
||||
expect(await career(page.request)).toEqual(source);
|
||||
expect(await readVariant(page.request, sibling.id)).toEqual(sibling);
|
||||
const markers = sample.kind === 'education' ? [tailoredTitle, 'Example Learning Centre', '2015', '2017']
|
||||
: sample.kind === 'repeated' ? [tailoredTitle, 'Repeat AS', 'Built first service.', 'Built second service.', 'Reviewed third service.', '2018', '2020', '2022', '2023']
|
||||
: [tailoredTitle, 'Ambiguous Labs', '2019', '2022'];
|
||||
await info.attach('pdf-semantic-oracle-input', { body: JSON.stringify({ pdfPath, sha256: sha(bytes), expectedLiteralMarkers: markers,
|
||||
canonicalEntryKey: first.key, canonicalTitle: first.title, cvOnlyTitle: tailoredTitle, theme: sample.theme,
|
||||
boundary: 'Actual downloaded PDF bytes; semantic extraction and physical/render oracle are separate root-run gates.' }), contentType: 'application/json' });
|
||||
await info.attach('actual-cv-pdf', { path: pdfPath, contentType: 'application/pdf' });
|
||||
return { cv: await readVariant(page.request, cv.id), tailoredTitle, source };
|
||||
}
|
||||
|
||||
async function freezeApplication(page: Page, headers: Headers, cv: CvVariant, tailoredTitle: string) {
|
||||
const company = await json<{ id: number }>(await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'CV QA synthetic application company', source: 'User' } }), 201);
|
||||
const job = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications`, { headers, data: {
|
||||
companyId: company.id, jobTitle: 'Reviewed QA engineering application', status: 'Saved', description: 'Synthetic source-backed qualification. No external delivery.',
|
||||
} }), 201);
|
||||
const url = `${apiUrl}/jobapplications/${job.id}`;
|
||||
await json(await page.request.put(`${url}/cv`, { headers, data: { variantId: cv.id } }));
|
||||
const initial = await json<CoverLetter>(await page.request.get(`${url}/cover-letter`));
|
||||
const text = 'Manually reviewed frozen application letter — æøå. Preserve this exact text across later import and CV edits.';
|
||||
await json(await page.request.put(`${url}/cover-letter`, { headers, data: { expectedVersion: initial.revision, text, source: 'manual' } }));
|
||||
const captured = await json<SubmittedPackage>(await page.request.post(`${url}/submitted-packages`, { headers }));
|
||||
const detailUrl = `${url}/submitted-packages/${captured.id}`;
|
||||
const detail = await json<SubmittedPackageDetail>(await page.request.get(detailUrl));
|
||||
expect(detail.coverLetterText).toBe(text);
|
||||
expect(detail.renderedCvHtml).toContain(tailoredTitle);
|
||||
expect(captured.cvVariantId).toBe(cv.id);
|
||||
return { jobId: job.id, url, detailUrl, detail, text, letter: await json<CoverLetter>(await page.request.get(`${url}/cover-letter`)) };
|
||||
}
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{ language: 'en', mode: 'light', width: 1440, file: 'frozen-ambiguous-employer-first.docx', kind: 'job', theme: 'minimal', package: true },
|
||||
{ language: 'nb', mode: 'dark', width: 390, file: 'frozen-ambiguous-title-first.pdf', kind: 'job', theme: 'technical' },
|
||||
{ language: 'en', mode: 'light', width: 768, file: 'frozen-ambiguous-education.docx', kind: 'education', theme: 'minimal' },
|
||||
{ language: 'en', mode: 'dark', width: 768, file: 'frozen-repeated-overlapping-roles.docx', kind: 'repeated', theme: 'technical' },
|
||||
];
|
||||
|
||||
for (const sample of scenarios) {
|
||||
test(`[CV-QA] real Docling ${sample.file} ${sample.language}/${sample.mode}/${sample.width}: source, explicit review, canonical persistence, scoped CV and PDF`, async ({ page, context, playwright }, info) => {
|
||||
const timings: Timings = {};
|
||||
try {
|
||||
const { email, headers } = await owner(page, context, playwright, sample, info);
|
||||
const t = translations[sample.language];
|
||||
const sibling = await createVariant(page.request, headers, 'Untouched pre-import sibling');
|
||||
const first = await upload(page, playwright, sample.language, sample.file, info, timings, 'first');
|
||||
let current: Snapshot;
|
||||
if (sample.kind === 'repeated') {
|
||||
await approveExtracted(page, first.runId, sample.language);
|
||||
await accessibility(page, first.runId, info, 'extracted-review');
|
||||
current = await apply(page, headers, first.runId, sample.language, first.before, timings, 'first');
|
||||
expect(current.revision).toBe(1);
|
||||
expect(current.profile.jobs).toHaveLength(3);
|
||||
expect(current.profile.jobs.map(job => [job.company, job.title, job.start, job.end])).toEqual([
|
||||
['Repeat AS', 'Engineer', '2018', '2021'], ['Repeat AS', 'Engineer', '2020', '2023'], ['Repeat AS', 'Lead', '2022', 'Present'],
|
||||
]);
|
||||
expect(current.profile.jobs.map(job => job.bullets)).toEqual([['Built first service.'], ['Built second service.'], ['Reviewed third service.']]);
|
||||
expect(current.profile.jobs[2].isCurrent).toBe(true);
|
||||
const origins = current.profile.jobs.map((_, index) => current.profile.metadata.fields[`jobs.${index}.company`]);
|
||||
expect(new Set(origins.map(origin => origin.sourceBlockId)).size).toBe(3);
|
||||
for (const [index, origin] of origins.entries()) {
|
||||
expect(origin.sourceExtractionRunId).toBe(first.runId);
|
||||
expect(origin.sourceArtifactId).toBeGreaterThan(0);
|
||||
expect(origin.sourceFileSha256).toBe(first.fixtureHash);
|
||||
expect(clean(first.review.sourceBlocks.find(block => block.id === origin.sourceBlockId)!.text)).toContain('Repeat AS');
|
||||
expect(first.review.sourceBlocks.find(block => block.id === current.profile.metadata.fields[`jobs.${index}.bullets.0`].sourceBlockId)!.text).toContain(current.profile.jobs[index].bullets[0]);
|
||||
}
|
||||
} else {
|
||||
expect(first.review.groups.some(group => group.category === 'jobs' || group.category === 'education')).toBe(false);
|
||||
await rejectOriginal(page, first.runId);
|
||||
const category = sample.kind === 'education' ? 'education' : 'jobs';
|
||||
const fields: MappedValue[] = sample.kind === 'education'
|
||||
? [{ name: 'qualification', value: 'Advanced Computing' }, { name: 'institution', value: 'Example Learning Centre' }, { name: 'start', value: '2015', evidence: '2015 - 2017' }, { name: 'end', value: '2017', evidence: '2015 - 2017' }]
|
||||
: [{ name: 'title', value: 'Principal Engineer' }, { name: 'company', value: 'Ambiguous Labs' }, { name: 'start', value: '2019', evidence: '2019 - 2022' }, { name: 'end', value: '2022', evidence: '2019 - 2022' }];
|
||||
const mapped = await author(page, first.runId, first.review, sample.language, category, fields);
|
||||
await accessibility(page, first.runId, info, 'mapped-review');
|
||||
current = await apply(page, headers, first.runId, sample.language, first.before, timings, 'first');
|
||||
expect(current.revision).toBe(1);
|
||||
expect(current.profile.otherSections).toEqual([]);
|
||||
expect(current.profile.jobs).toHaveLength(sample.kind === 'education' ? 0 : 1);
|
||||
expect(current.profile.education).toHaveLength(sample.kind === 'education' ? 1 : 0);
|
||||
const record = sample.kind === 'education' ? current.profile.education[0] : current.profile.jobs[0];
|
||||
expect(record.id).toBeTruthy();
|
||||
for (const field of mapped.selected) {
|
||||
expect(record).toHaveProperty(field.name, field.value);
|
||||
expect(current.profile.metadata.fields[`${category}.0.${field.name}`]).toMatchObject({ method: 'user-source-mapping', sourceBlockId: field.block.id, sourceExtractionRunId: first.runId, sourceFileSha256: first.fixtureHash, reviewState: 'accepted' });
|
||||
expect(current.profile.metadata.fields[`${category}.0.${field.name}`].sourceArtifactId).toBeGreaterThan(0);
|
||||
}
|
||||
expect((await versions(page.request))).toHaveLength(1);
|
||||
}
|
||||
const stableIds = [...current.profile.jobs, ...current.profile.education].map(record => record.id);
|
||||
expect(stableIds.every(Boolean)).toBe(true);
|
||||
expect(new Set(stableIds).size).toBe(stableIds.length);
|
||||
expect(await readVariant(page.request, sibling.id)).toEqual(sibling);
|
||||
const edited = await editAndExport(page, headers, sample, sibling, info, timings);
|
||||
|
||||
if (sample.kind === 'repeated') {
|
||||
const repeated = await upload(page, playwright, sample.language, sample.file, info, timings, 'same-source');
|
||||
await approveExtracted(page, repeated.runId, sample.language);
|
||||
const reapplied = await apply(page, headers, repeated.runId, sample.language, repeated.before, timings, 'same-source');
|
||||
expect(reapplied.revision).toBe(2);
|
||||
expect(reapplied.profile.jobs).toHaveLength(3);
|
||||
expect(reapplied.profile.jobs.map(job => job.id)).toEqual(stableIds);
|
||||
expect(reapplied.profile.jobs.map(job => job.bullets)).toEqual(current.profile.jobs.map(job => job.bullets));
|
||||
expect(new Set(reapplied.profile.jobs.map((_, index) => reapplied.profile.metadata.fields[`jobs.${index}.company`].sourceBlockId)).size).toBe(3);
|
||||
for (const index of [0, 1, 2]) expect(reapplied.profile.metadata.fields[`jobs.${index}.company`].sourceExtractionRunId).toBe(repeated.runId);
|
||||
expect(await versions(page.request)).toHaveLength(2);
|
||||
}
|
||||
|
||||
if (sample.package) {
|
||||
const frozen = await freezeApplication(page, headers, edited.cv, edited.tailoredTitle);
|
||||
const next = await upload(page, playwright, sample.language, sample.file, info, timings, 'target-conflict');
|
||||
await rejectOriginal(page, next.runId);
|
||||
const draft = await author(page, next.runId, next.review, sample.language, 'jobs', [{ name: 'title', value: 'Principal Engineer' }, { name: 'company', value: 'Ambiguous Labs' }], 0);
|
||||
await expect(draft.mapping.getByText(t.careerImportCurrentValue.replace('{value}', 'Principal Engineer'), { exact: true })).toBeVisible();
|
||||
const concurrent = await playwright.request.newContext();
|
||||
let changed: Snapshot;
|
||||
try {
|
||||
const concurrentHeaders = await login(concurrent, email);
|
||||
const latest = await career(concurrent);
|
||||
const profile = { ...latest.profile, jobs: latest.profile.jobs.map((job, index) => index === 0 ? { ...job, title: 'Concurrent owner-reviewed Engineer' } : job) };
|
||||
await json(await concurrent.put(`${apiUrl}/career/profile`, { headers: concurrentHeaders, data: { expectedVersion: latest.revision, profile } }));
|
||||
changed = await career(concurrent);
|
||||
} finally { await concurrent.dispose(); }
|
||||
const conflict = page.waitForResponse(response => response.url() === `${apiUrl}/profile-cv/runs/${next.runId}/apply-reviewed` && response.request().method() === 'POST');
|
||||
await keyboardClick(page, panel(page, next.runId).getByRole('button', { name: t.cvImportApplyReviewed, exact: true }));
|
||||
expect((await conflict).status()).toBe(409);
|
||||
await expect(page.getByText(t.cvImportStaleReview, { exact: true })).toBeVisible();
|
||||
expect(await career(page.request)).toEqual(changed!);
|
||||
await expect(draft.mapping.getByRole('textbox', { name: numbered(t.cvImportMappingValue, 1), exact: true })).toHaveValue('Principal Engineer');
|
||||
const target = draft.mapping.getByRole('combobox', { name: t.cvImportMappingTarget, exact: true });
|
||||
await expect(target).toHaveValue('');
|
||||
await expect(panel(page, next.runId).getByRole('button', { name: t.cvImportApplyReviewed, exact: true })).toBeDisabled();
|
||||
await target.selectOption('0');
|
||||
await expect(draft.mapping.getByText(t.careerImportCurrentValue.replace('{value}', 'Concurrent owner-reviewed Engineer'), { exact: true })).toBeVisible();
|
||||
await rejectOriginal(page, next.runId);
|
||||
await draft.mapping.getByRole('button', { name: t.cvImportMappingConfirm, exact: true }).click();
|
||||
const final = await apply(page, headers, next.runId, sample.language, changed!, timings, 'target-reconfirmed');
|
||||
expect(final.revision).toBe(3);
|
||||
expect(final.profile.jobs).toHaveLength(1);
|
||||
expect(final.profile.jobs[0].id).toBe(stableIds[0]);
|
||||
expect(final.profile.jobs[0]).toMatchObject({ title: 'Principal Engineer', company: 'Ambiguous Labs', start: '2019', end: '2022' });
|
||||
expect(final.profile.metadata.fields['jobs.0.start'].sourceExtractionRunId).toBe(first.runId);
|
||||
expect(final.profile.metadata.fields['jobs.0.title'].sourceExtractionRunId).toBe(next.runId);
|
||||
const latestCv = await readVariant(page.request, edited.cv.id);
|
||||
await json(await page.request.put(`${apiUrl}/cv/variants/${latestCv.id}`, { headers, data: { name: latestCv.name, settings: { ...latestCv.settings, headline: 'Later CV-only headline' }, expectedVersion: latestCv.version, source: 'manual' } }));
|
||||
expect(await json(await page.request.get(frozen.detailUrl))).toEqual(frozen.detail);
|
||||
expect(await json(await page.request.get(`${frozen.url}/cover-letter`))).toEqual(frozen.letter);
|
||||
expect(await versions(page.request)).toHaveLength(3);
|
||||
await page.goto(`/jobs/${frozen.jobId}?section=application&sub=letter`);
|
||||
await expect(page.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true })).toHaveValue(frozen.text);
|
||||
await info.attach('frozen-application-preservation', { body: JSON.stringify({ packageId: frozen.detail.summary.id, renderedCvSha256: createHash('sha256').update(frozen.detail.renderedCvHtml!).digest('hex'), sourceRevisions: [1, changed!.revision, final.revision], letter: 'Exact DTO/text/history unchanged', packageDetail: 'Exact immutable detail unchanged', delivery: 'No send or external email endpoint called' }), contentType: 'application/json' });
|
||||
}
|
||||
expect(await readVariant(page.request, sibling.id)).toEqual(sibling);
|
||||
const sourceAfter = await page.request.get(`${apiUrl}/profile-cv/runs/${first.runId}/source`);
|
||||
expect(sourceAfter.status()).toBe(200);
|
||||
expect(sha(await sourceAfter.body())).toBe(first.fixtureHash);
|
||||
} finally {
|
||||
await info.attach('actual-latencies', { body: JSON.stringify({ measurementsMs: timings, boundary: 'Measured local scenario samples, not an SLA, production load claim, live model result or human/device acceptance.' }), contentType: 'application/json' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import imports from './playwright.cv-import.config';
|
||||
|
||||
if (!process.env.CV_QA_FIXTURE_ROOT) throw new Error('Set CV_QA_FIXTURE_ROOT to the frozen structural-corpus-01 documents.');
|
||||
|
||||
export default defineConfig({
|
||||
...imports,
|
||||
testMatch: ['cv-import-docling.spec.ts', 'cv-integration-qa.spec.ts'],
|
||||
timeout: 420_000,
|
||||
outputDir: '.artifacts/cv-integration-qa-browser',
|
||||
});
|
||||
Reference in New Issue
Block a user