Files
jobtrackingapp/job-tracker-ui/e2e/cv-readiness.spec.ts
T

211 lines
17 KiB
TypeScript

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<void>(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();
});