import { expect, test, type APIRequestContext } from "@playwright/test"; import fs from "node:fs/promises"; import path from "node:path"; const apiUrl = "http://localhost:5302/api"; const evidence = path.resolve(process.env.CV_FIDELITY_OUTPUT || "../.artifacts/p5-09-readiness/rendering"); async function authenticate(api: APIRequestContext) { expect((await api.post(`${apiUrl}/auth/login`, { data: { email: "e2e@example.test", password: "E2ePassword123!" } })).status()).toBe(200); const me = await (await api.get(`${apiUrl}/auth/me`)).json(); const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === "XSRF-TOKEN")!.value; return { "X-CSRF-TOKEN": csrf, "X-Session-Context": me.sessionContext as string }; } const shortProfile = { version: "1", contact: { fullName: "Åse Fixture", headline: "Career platform engineer", email: "ase@example.test", location: "Oslo, Norge" }, summary: ["R01 Builds reliable and accessible career software using documented evidence."], jobs: [{ id: "role-1", title: "E01 Platform engineer", company: "Nordlys Fixture AS", startDate: "2021-01", isCurrent: true, bullets: ["B01 Delivered accessible application workflows with documented outcomes.", "B02 Maintained reliable APIs and verified releases."] }], education: [{ id: "education-1", qualification: "E02 Computer science", institution: "Fixture University", startDate: "2016-08", endDate: "2020-06" }], projects: [{ id: "project-1", name: "P01 Evidence workspace", bullets: ["B03 Created a source-grounded review workflow."] }], skills: ["C#", "React", "Norsk", "Accessibility"], languages: [{ name: "Norsk", level: "Native" }], interests: ["HIDDEN-MARKER Hiking"], }; // Fixed text, IDs and settings; no randomized content or external provider. function longProfile(language: "en" | "nb") { const sentence = language === "nb" ? "Utviklet pålitelige arbeidsflyter med tilgjengelig navigasjon, dokumentert kildegrunnlag og tydelige vurderinger for norske arbeidssøkere. " : "Developed reliable workflows with accessible navigation, documented source evidence and explicit review for people managing their careers. "; return { ...shortProfile, contact: { ...shortProfile.contact, headline: language === "nb" ? "Plattformutvikler med erfaring fra tilgjengelige, pålitelige arbeidsflyter" : "Platform engineer with experience in accessible and reliable application workflows" }, jobs: Array.from({ length: 6 }, (_, index) => ({ id: `role-${index + 1}`, title: `E${String(index + 1).padStart(2, "0")} ${language === "nb" ? "Utvikler av dokumenterte arbeidsflyter og integrasjoner" : "Workflow and integration engineer"}`, company: `Nordlys Fixture ${index + 1}`, startDate: "2017-01", endDate: "2025-12", bullets: Array.from({ length: 3 }, (_, bullet) => `B${String(index * 3 + bullet + 1).padStart(2, "0")} ${sentence.repeat(bullet === 2 ? 8 : 3)}`) })), education: [...shortProfile.education, { id: "education-2", qualification: "E08 Videreutdanning i universell utforming og informasjonsarkitektur", institution: "Fixture Academy", startDate: "2021-01", endDate: "2022-06" }], }; } test("[PRES-C06 C08 C09 C12] native preview/PDF fidelity corpus records unchanged output at fixed settings", async ({ page, context, browser }, testInfo) => { test.setTimeout(360_000); 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 context.addInitScript(() => localStorage.setItem("uiLanguage", "en")); const headers = await authenticate(page.request); const themes = await (await page.request.get(`${apiUrl}/cv/themes`)).json(); const fixtures = [ ...themes.map((theme: { id: string }) => ({ key: `default-${theme.id}`, themeId: theme.id, language: "en", long: false, settings: {} })), { key: "long-en-minimal", themeId: "minimal", language: "en", long: true, settings: {} }, { key: "long-nb-code", themeId: "code", language: "nb", long: true, settings: {} }, { key: "compact-8mm-letter", themeId: "ats-classic", language: "en", long: true, settings: { pageMarginMm: 8, density: "compact", pageSize: "letter", sectionGapMm: 3, entryGapMm: 2 } }, { key: "roomy-28mm-nb", themeId: "modern", language: "nb", long: true, settings: { pageMarginMm: 28, density: "roomy", lineHeight: 1.6 } }, { key: "grid-and-bubbles", themeId: "minimal", language: "en", long: false, settings: { sections: [{ key: "projects", presentation: "grid", columns: 2 }, { key: "skills", presentation: "bubble" }, { key: "experience" }, { key: "interests", hidden: true }], accentColor: "#126b55" } }, { key: "long-sidebar-nb", themeId: "technical", language: "nb", long: true, settings: { sidebarWidthMm: 68, skillsStyle: "grouped", skillGroups: [{ name: "Samarbeid", items: ["Norsk", "Tilgjengelighet"] }] } }, { key: "unbreakable-and-boundary", themeId: "code", language: "en", long: true, settings: { customSections: [{ key: "boundary", title: "S09 Boundary content", contentType: "paragraphs", items: [`R09 ${"UnbreakableEvidence".repeat(40)}`, "R10 Final recorded paragraph — æøå."] }] } }, ]; await fs.mkdir(evidence, { recursive: true }); const results = []; for (const fixture of fixtures) { const profile = fixture.long ? longProfile(fixture.language as "en" | "nb") : shortProfile; expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200); const create = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: fixture.key, settings: { themeId: fixture.themeId, language: fixture.language, sections: [{ key: "summary" }, { key: "experience" }, { key: "education" }, { key: "projects" }, { key: "skills" }, { key: "languages" }, { key: "interests", hidden: true }], ...fixture.settings, } } }); expect(create.status(), fixture.key).toBe(200); const variant = await create.json(); const before = await (await page.request.get(`${apiUrl}/career/profile`)).json(); await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(`/career/builder/${variant.id}`); await expect(page.getByTitle("CV preview page 1", { exact: true })).toBeVisible(); const iframe = page.getByTitle("CV preview", { exact: true }); await expect.poll(async () => iframe.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement?.dataset.cvPreviewReady)).toBe("true"); const measured = await iframe.evaluate(async element => { const frame = element as HTMLIFrameElement; const doc = frame.contentDocument!; await doc.fonts.ready; await new Promise(resolve => frame.contentWindow!.requestAnimationFrame(() => frame.contentWindow!.requestAnimationFrame(() => resolve()))); const win = frame.contentWindow!; const geometry = doc.documentElement.dataset; const physicalWidth = Number(geometry.cvPreviewWidth); const physicalHeight = Number(geometry.cvPreviewHeight); const css = (selector: string) => { const node = doc.querySelector(selector); if (!node) return null; const style = win.getComputedStyle(node); const rect = node.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, font: style.fontFamily, fontSize: style.fontSize, lineHeight: style.lineHeight, color: style.color, background: style.backgroundColor, padding: style.padding, margin: style.margin }; }; const tokens = []; const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT); let node; while ((node = walker.nextNode())) { if (["SCRIPT", "STYLE"].includes(node.parentElement?.tagName || "")) continue; for (const match of (node.textContent || "").matchAll(/\b[SEBPR]\d{2}\b/g)) { const range = doc.createRange(); range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length); const rect = range.getClientRects()[0]; const style = win.getComputedStyle(node.parentElement!); // Native screen columns are horizontal. Normalize to the same physical-page // coordinate system used by the unchanged PDF oracle, retaining raw evidence. const pageIndex = Math.floor(rect.x / physicalWidth); tokens.push({ token: match[0], x: rect.x - pageIndex * physicalWidth, y: rect.y + pageIndex * physicalHeight, rawX: rect.x, rawY: rect.y, page: pageIndex + 1, width: rect.width, height: rect.height, fontSize: style.fontSize, fontFamily: style.fontFamily, color: style.color }); } } return { width: physicalWidth, contentHeight: Number(geometry.cvPreviewPages) * physicalHeight, usableHeight: Number(geometry.cvPreviewUsableHeight), layoutDurationMs: Number(geometry.cvPreviewDurationMs), text: doc.body.innerText, tokens, body: css("body"), page: css(".page"), header: css(".header,.hero"), main: css(".main"), sidebar: css(".sidebar"), parsedRules: Array.from(doc.styleSheets).flatMap(sheet => Array.from(sheet.cssRules).map(rule => rule.cssText)), sections: Array.from(doc.querySelectorAll(".section")).map(node => ({ key: node.className, title: node.querySelector("h2")?.textContent, fragments: Array.from(node.getClientRects()).map(rect => ({ x: rect.x, y: rect.y, width: rect.width, height: rect.height })) })), effectiveMargin: win.getComputedStyle(doc.documentElement).getPropertyValue("--cv-page-margin"), userAgent: win.navigator.userAgent }; }); expect(measured.text, fixture.key).not.toContain("HIDDEN-MARKER"); const pdf = await page.request.post(`${apiUrl}/cv/variants/${variant.id}/export-pdf`, { headers }); expect(pdf.status(), fixture.key).toBe(200); const directory = path.join(evidence, fixture.key); await fs.mkdir(directory, { recursive: true }); const rendered = await (await page.request.get(`${apiUrl}/cv/variants/${variant.id}/preview`)).json(); await fs.writeFile(path.join(directory, "render.html"), rendered.html); await fs.writeFile(path.join(directory, "export.pdf"), await pdf.body()); const pageCount = await page.locator("[data-cv-page]").count(); // Document-only frames retain their physical dimensions, independent of application zoom. for (let index = 1; index <= pageCount; index++) { const frame = page.getByTitle(`CV preview page ${index}`, { exact: true }); const srcDoc = await frame.getAttribute("srcDoc"); const height = (variant.settings.pageSize === "letter" ? 279.4 : 297) * 96 / 25.4; const capture = await browser.newPage({ viewport: { width: Math.ceil(measured.width), height: Math.ceil(height) } }); try { await capture.setContent(srcDoc!); await expect.poll(() => capture.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe("true"); await capture.screenshot({ path: path.join(directory, `preview-${index}.png`) }); } finally { await capture.close(); } } expect((await (await page.request.get(`${apiUrl}/cv/variants/${variant.id}`)).json()).version).toBe(variant.version); expect((await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile).toEqual(before.profile); results.push({ fixture: fixture.key, settings: variant.settings, previewPages: pageCount, measured }); } await fs.writeFile(path.join(evidence, "measurements.json"), JSON.stringify({ browserVersion: browser.version(), fixtures: results }, null, 2)); await testInfo.attach("fidelity-measurements", { path: path.join(evidence, "measurements.json"), contentType: "application/json" }); }); test("[PRES-C10 E05] independent authenticated clients cannot save or restore a stale CV revision", async ({ page, playwright }) => { const headers = await authenticate(page.request); const otherSession = await playwright.request.newContext(); try { const secondHeaders = await authenticate(otherSession); const created = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "Revision API fixture" } }); const cv = await created.json(); const retained = await (await otherSession.get(`${apiUrl}/cv/variants/${cv.id}`)).json(); const missing = await otherSession.put(`${apiUrl}/cv/variants/${cv.id}`, { headers: secondHeaders, data: { settings: retained.settings } }); expect(missing.status()).toBe(428); expect((await missing.json()).code).toBe("cv_revision_required"); const missingRestore = await otherSession.post(`${apiUrl}/cv/variants/${cv.id}/versions/1/restore`, { headers: secondHeaders }); expect(missingRestore.status()).toBe(428); for (const expectedVersion of [0, -1]) { expect((await otherSession.put(`${apiUrl}/cv/variants/${cv.id}`, { headers: secondHeaders, data: { expectedVersion, settings: retained.settings } })).status()).toBe(428); } const writes = await Promise.all([ page.request.put(`${apiUrl}/cv/variants/${cv.id}`, { headers, data: { expectedVersion: cv.version, settings: { ...cv.settings, headline: "First contender" } } }), otherSession.put(`${apiUrl}/cv/variants/${cv.id}`, { headers: secondHeaders, data: { expectedVersion: retained.version, settings: { ...retained.settings, headline: "Second contender" } } }), ]); expect(writes.map(response => response.status()).sort()).toEqual([200, 409]); const winner = await (await page.request.get(`${apiUrl}/cv/variants/${cv.id}`)).json(); expect(winner.version).toBe(2); const restore = await otherSession.post(`${apiUrl}/cv/variants/${cv.id}/versions/1/restore`, { headers: secondHeaders, data: { expectedVersion: retained.version } }); expect(restore.status()).toBe(409); expect((await restore.json()).code).toBe("cv_revision_conflict"); expect(await (await page.request.get(`${apiUrl}/cv/variants/${cv.id}`)).json()).toEqual(winner); const restored = await otherSession.post(`${apiUrl}/cv/variants/${cv.id}/versions/1/restore`, { headers: secondHeaders, data: { expectedVersion: winner.version } }); expect(restored.status()).toBe(200); expect((await restored.json()).version).toBe(3); const history = await (await page.request.get(`${apiUrl}/cv/variants/${cv.id}/versions`)).json(); expect(history.map((version: { version: number }) => version.version)).toEqual([3, 2, 1]); } finally { await otherSession.dispose(); } }); test("[PRES-C06 C10 E08] stale editor retains and downloads its draft; reviewed reload resumes versioned saves", async ({ page, context }, testInfo) => { test.setTimeout(90_000); await context.addInitScript(() => localStorage.setItem("uiLanguage", "en")); const headers = await authenticate(page.request); const cv = await (await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "Recovery fixture", settings: { themeId: "minimal" } } })).json(); await page.goto(`/career/builder/${cv.id}`); await page.getByRole("tab", { name: "Content", exact: true }).click(); const headline = page.getByLabel("Headline override", { exact: true }); await expect(headline).toBeVisible(); const remote = await page.request.put(`${apiUrl}/cv/variants/${cv.id}`, { headers, data: { expectedVersion: cv.version, settings: { ...cv.settings, headline: "Remote reviewed edit" } } }); expect(remote.status()).toBe(200); await headline.fill("Local recoverable draft æøå"); await page.getByRole("button", { name: "Save now", exact: true }).click(); await expect(page.getByText("CV changed elsewhere", { exact: true })).toBeVisible(); await expect(headline).toHaveValue("Local recoverable draft æøå"); const downloadPromise = page.waitForEvent("download"); await page.getByRole("button", { name: "Download unsaved copy", exact: true }).click(); const download = await downloadPromise; const draft = JSON.parse(await fs.readFile((await download.path())!, "utf8")); expect(draft.settings.headline).toBe("Local recoverable draft æøå"); expect(draft.expectedVersion).toBe(1); expect((await (await page.request.get(`${apiUrl}/cv/variants/${cv.id}`)).json()).settings.headline).toBe("Remote reviewed edit"); await page.getByRole("button", { name: "Reload saved CV", exact: true }).click(); await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click(); await expect(page.getByRole("dialog")).toHaveCount(0); await expect(headline).toHaveValue("Local recoverable draft æøå"); await page.screenshot({ path: testInfo.outputPath("conflict-draft-retained.png"), fullPage: true }); await page.getByRole("button", { name: "Reload saved CV", exact: true }).click(); await page.getByRole("dialog").getByRole("button", { name: "Reload saved CV", exact: true }).click(); await expect(page.getByRole("dialog")).toHaveCount(0); await expect(headline).toHaveValue("Remote reviewed edit"); await headline.fill("Reviewed combined edit"); await page.getByRole("button", { name: "Save now", exact: true }).click(); await expect(page.getByText("Saved", { exact: true })).toBeVisible(); expect((await (await page.request.get(`${apiUrl}/cv/variants/${cv.id}`)).json()).version).toBe(3); await page.getByRole("tab", { name: "History", exact: true }).click(); await expect(page.getByText("Version 1", { exact: true })).toBeVisible(); });