import { expect, test as base, type APIRequestContext } from "@playwright/test"; import { createHash } from "node:crypto"; import { buildJobWorkspacePath } from "../src/jobWorkspaceRoute"; import AxeBuilder from "@axe-core/playwright"; const apiUrl = "http://localhost:5302/api"; const password = "E2ePassword123!"; const canonical = { version: "1", contact: { fullName: "Åse Eksempel", headline: "Canonical engineer" }, jobs: [{ title: "Engineer", company: "Nordlys AS", bullets: ["Built reliable APIs"] }], skills: ["C#", "Norsk"], }; async function login(api: APIRequestContext, email: string) { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).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 }; } // Every case gets a real non-admin account in Playwright's disposable local API database. // No API responses are mocked; browser traffic cannot leave the local test environment. const test = base.extend<{ headers: Record }>({ headers: async ({ playwright, page, context }, provide) => { const admin = await playwright.request.newContext(); try { const adminHeaders = await login(admin, "e2e@example.test"); const email = `pres-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200); const headers = await login(page.request, email); 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")); expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: canonical } })).status()).toBe(200); await provide(headers); } finally { await admin.dispose(); } }, }); test("[PRES-C01 PRES-C06 PRES-C07 PRES-C10] mobile CV override persists without changing the canonical profile or sibling CV", async ({ page, headers }) => { test.setTimeout(60_000); // Real API setup, mobile editing, preview and reload share this journey. const create = async (name: string) => { const response = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name } }); expect(response.status()).toBe(200); return response.json(); }; const selected = await create("Selected CV"); const sibling = await create("Sibling CV"); const profileBefore = await (await page.request.get(`${apiUrl}/career/profile`)).json(); await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`/career/builder/${selected.id}`); await page.getByRole("tab", { name: "Content", exact: true }).click(); await page.getByLabel("Headline override", { exact: true }).fill("Reviewed CV-only headline"); await page.getByRole("button", { name: "Save now", exact: true }).click(); await expect(page.getByText("Saved", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Preview", exact: true }).click(); await expect(page.getByTitle("CV preview page 1")).toBeVisible(); await page.getByRole("button", { name: "Edit", exact: true }).click(); await expect(page.getByLabel("Headline override", { exact: true })).toHaveValue("Reviewed CV-only headline"); await page.reload(); await page.getByRole("tab", { name: "Content", exact: true }).click(); await expect(page.getByLabel("Headline override", { exact: true })).toHaveValue("Reviewed CV-only headline"); await page.setViewportSize({ width: 1440, height: 900 }); await expect(page.getByTitle("CV preview page 1")).toBeVisible(); expect((await (await page.request.get(`${apiUrl}/cv/variants/${selected.id}`)).json()).settings.headline).toBe("Reviewed CV-only headline"); expect(await (await page.request.get(`${apiUrl}/cv/variants/${sibling.id}`)).json()).toEqual(sibling); expect((await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile).toEqual(profileBefore.profile); }); test("[PRES-W01 PRES-W05 PRES-W06 PRES-W07 PRES-W08] legacy workspace preserves frozen documents after current sources change", async ({ page, headers, playwright }, testInfo) => { test.setTimeout(90_000); const companyResponse = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Preservation AS" } }); expect(companyResponse.status()).toBe(201); const company = await companyResponse.json(); const jobResponse = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: company.id, jobTitle: "Preserved workspace", status: "Saved" } }); expect(jobResponse.ok()).toBe(true); const job = await jobResponse.json(); const variantResponse = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "Submitted CV", jobApplicationId: job.id } }); expect(variantResponse.status()).toBe(200); const variant = await variantResponse.json(); const siblingResponse = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "Sibling CV" } }); expect(siblingResponse.status()).toBe(200); const sibling = await siblingResponse.json(); const jobUrl = `${apiUrl}/jobapplications/${job.id}`; expect((await page.request.put(`${jobUrl}/cv`, { headers, data: { variantId: variant.id } })).status()).toBe(200); expect((await page.request.put(`${jobUrl}/cover-letter`, { headers, data: { text: "Original reviewed letter", source: "manual" } })).status()).toBe(200); expect((await page.request.put(`${jobUrl}/application-drafts`, { headers, data: { applicationAnswerDraft: "Original answer — æøå", recruiterMessageDraft: "Original recruiter message" } })).status()).toBe(204); expect((await page.request.get(`${jobUrl}/tailoring`)).status()).toBe(200); const bytes = Buffer.from("Original attachment — æøå\n", "utf8"); const upload = await page.request.post(`${apiUrl}/attachments`, { headers, multipart: { jobId: String(job.id), files: { name: "evidence.txt", mimeType: "text/plain", buffer: bytes } } }); expect(upload.ok()).toBe(true); const [attachment] = await (await page.request.get(`${apiUrl}/attachments/${job.id}`)).json(); await page.goto(`/applications/${job.id}?section=cv`); await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=cv$`)); await page.getByRole("tab", { name: "Submission", exact: true }).click(); const captured = page.waitForResponse(response => response.url() === `${jobUrl}/submitted-packages` && response.request().method() === 'POST'); await page.getByRole("button", { name: "Freeze current package", exact: true }).click(); const captureResponse = await captured; // P2-D01 is now an ordinary regression: native capture AND preserved readback // must succeed; a capture 500 can no longer be classified as expected. expect(captureResponse.status()).toBe(200); await expect(page.getByText("Submission package v1", { exact: true })).toBeVisible(); const [snapshot] = await (await page.request.get(`${jobUrl}/submitted-packages`)).json(); const before = await (await page.request.get(`${jobUrl}/submitted-packages/${snapshot.id}`)).json(); expect(before.coverLetterText).toBe("Original reviewed letter"); expect(before.renderedCvHtml).toContain("Canonical engineer"); expect(snapshot.cvVariantId).toBe(variant.id); expect(snapshot.cvVariantVersion).toBe(variant.version); expect(before.applicationMaterialJson).toContain("Original answer"); expect(snapshot.attachments).toHaveLength(1); expect((await page.request.put(`${jobUrl}/cover-letter`, { headers, data: { text: "Later letter", source: "manual" } })).status()).toBe(200); expect((await page.request.put(`${apiUrl}/cv/variants/${variant.id}`, { headers, data: { name: "Later CV", settings: { ...variant.settings, headline: "Later headline" }, expectedVersion: variant.version } })).status()).toBe(200); expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { ...canonical, contact: { ...canonical.contact, headline: "Later canonical headline" } } } })).status()).toBe(200); expect((await page.request.put(`${jobUrl}/application-drafts`, { headers, data: { applicationAnswerDraft: "Later answer", recruiterMessageDraft: "Later recruiter message" } })).status()).toBe(204); expect((await page.request.delete(`${apiUrl}/attachments/${attachment.id}`, { headers })).status()).toBe(204); expect(await (await page.request.get(`${jobUrl}/submitted-packages/${snapshot.id}`)).json()).toEqual(before); const archived = await page.request.get(`${jobUrl}/submitted-packages/${snapshot.id}/attachments/${snapshot.attachments[0].id}`); expect(archived.status()).toBe(200); expect(await archived.body()).toEqual(bytes); expect((await page.request.put(`${jobUrl}/cv`, { headers, data: { variantId: null } })).status()).toBe(200); expect((await page.request.get(`${apiUrl}/cv/variants/${variant.id}`)).status()).toBe(200); expect(await (await page.request.get(`${apiUrl}/cv/variants/${sibling.id}`)).json()).toEqual(sibling); const restored = await page.request.post(`${jobUrl}/cover-letter/versions/1/restore`, { headers }); expect(restored.status()).toBe(200); expect((await restored.json()).currentVersion).toBe(3); expect((await (await page.request.get(`${jobUrl}/cover-letter`)).json()).text).toBe("Original reviewed letter"); expect(await (await page.request.get(`${jobUrl}/submitted-packages/${snapshot.id}`)).json()).toEqual(before); await page.reload(); await page.getByRole("button", { name: "View preserved package", exact: true }).click(); await expect(page.getByRole("dialog").getByText("Original reviewed letter", { exact: true })).toBeVisible(); await testInfo.attach("native-frozen-package", { body: await page.screenshot({ fullPage: true }), contentType: "image/png" }); await testInfo.attach("native-preservation-invariants", { body: JSON.stringify({ packageId: snapshot.id, version: snapshot.version, cvVariantId: snapshot.cvVariantId, cvVariantVersion: snapshot.cvVariantVersion, attachment: { name: snapshot.attachments[0].fileName, sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length }, immutableReadback: true, canonicalAndVariantAndLetterAndAnswerMutated: true, liveAttachmentDeleted: true, siblingUnchanged: true, detachPreservedVariant: true }), contentType: "application/json" }); const admin = await playwright.request.newContext(); const other = await playwright.request.newContext(); try { const adminHeaders = await login(admin, "e2e@example.test"); const email = `package-other-${Date.now()}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200); const otherHeaders = await login(other, email); for (const path of [`${jobUrl}/submitted-packages`, `${jobUrl}/submitted-packages/${snapshot.id}`, `${jobUrl}/submitted-packages/${snapshot.id}/attachments/${snapshot.attachments[0].id}`]) expect((await other.get(path)).status()).toBe(404); expect((await other.put(`${jobUrl}/cv`, { headers: otherHeaders, data: { variantId: variant.id } })).status()).toBe(404); expect((await other.post(`${jobUrl}/submitted-packages`, { headers: otherHeaders })).status()).toBe(404); const otherCompany = await other.post(`${apiUrl}/companies`, { headers: otherHeaders, data: { name: "Other-owner AS" } }); expect(otherCompany.status()).toBe(201); const otherJob = await other.post(`${apiUrl}/jobapplications`, { headers: otherHeaders, data: { companyId: (await otherCompany.json()).id, jobTitle: "Other owner", status: "Saved" } }); expect(otherJob.status()).toBe(201); expect((await other.put(`${apiUrl}/jobapplications/${(await otherJob.json()).id}/cv`, { headers: otherHeaders, data: { variantId: variant.id } })).status()).toBe(404); } finally { await other.dispose(); await admin.dispose(); } }); for (const language of ["en", "nb"] as const) { for (const theme of ["light", "dark"] as const) { test(`[PRES-W01 PRES-W04 PRES-W08 PRES-E08 PRES-E09] native workspace aliases, keyboard sections and history retain context (${language}, ${theme})`, async ({ page, headers }, testInfo) => { test.setTimeout(120_000); await page.addInitScript(({ locale, mode }) => { localStorage.setItem("uiLanguage", locale); localStorage.setItem("jobtracker.themeMode", mode); }, { locale: language, mode: theme }); expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).status()).toBe(204); const companyResponse = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Navigasjon AS" } }); expect(companyResponse.status()).toBe(201); const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: (await companyResponse.json()).id, jobTitle: "Readiness workspace", status: "Saved", description: "C# engineer" } }); expect(created.ok()).toBe(true); const job = await created.json(); const expectedSections = ["overview", "communication", "documents", "cv", "communication", "match", "analysis", "interview", "checklist", "timeline"]; for (let tab = 0; tab < expectedSections.length; tab++) { const path = buildJobWorkspacePath(job.id, { tab, followMode: "waiting-update" }); await page.goto(path); await expect(page.getByRole("heading", { name: "Readiness workspace", exact: true })).toBeVisible(); expect(new URL(page.url()).searchParams.get("followMode")).toBe("waiting-update"); const section = expectedSections[tab]; const names = language === "en" ? { communication: "Timeline", documents: "Documents", checklist: "Notes & tasks", timeline: "Timeline", cv: "Application", match: "Match", analysis: "Match", overview: "Overview", interview: "Interview Prep" } : { communication: "Tidslinje", documents: "Dokumenter", checklist: "Notater og oppgaver", timeline: "Tidslinje", cv: "Søknad", match: "Samsvar", analysis: "Samsvar", overview: "Oversikt", interview: "Intervjuforberedelse" }; await expect(page.getByRole("tablist", { name: language === "en" ? "Workspace sections" : "Deler av arbeidsområdet" }).getByRole("tab", { name: names[section as keyof typeof names], exact: true })).toHaveAttribute("aria-selected", "true"); if (section === "communication") await expect(page.getByRole("tabpanel", { name: language === "en" ? "Communication" : "Kommunikasjon", exact: true })).toBeVisible(); await expect(page.getByRole("tabpanel").first()).toBeVisible(); } await page.goto(`/jobs/${job.id}?section=checklist&followMode=waiting-update`); const move = page.getByRole("button", { name: language === "en" ? /^Move up:/ : /^Flytt opp:/ }).first(); await move.focus(); await expect(move).toBeFocused(); await expect.poll(() => move.evaluate(node => getComputedStyle(node.closest(".checklist-actions")!).opacity)).toBe("1"); await testInfo.attach(`workspace-native-desktop-${language}-${theme}`, { body: await page.screenshot({ fullPage: true }), contentType: "image/png" }); await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`/jobs/${job.id}?section=documents&followMode=waiting-update`); await page.reload(); await expect(page.getByRole("heading", { name: "Readiness workspace", exact: true })).toBeVisible(); const navigationScan = await new AxeBuilder({ page }).include('[role="tablist"]').analyze(); await testInfo.attach("navigation-axe", { body: JSON.stringify({ violations: navigationScan.violations, incomplete: navigationScan.incomplete }), contentType: "application/json" }); expect(navigationScan.violations).toEqual([]); await testInfo.attach(`workspace-native-disclosure-${language}-${theme}`, { body: await page.screenshot({ fullPage: true }), contentType: "image/png" }); const overviewTab = page.getByRole("tab", { name: language === "en" ? "Overview" : "Oversikt", exact: true }); await overviewTab.focus(); await page.keyboard.press("ArrowRight"); const analysisTab = page.getByRole("tab", { name: language === "en" ? "Match" : "Samsvar", exact: true }); await expect(analysisTab).toBeFocused(); await page.keyboard.press("Enter"); await expect(analysisTab).toHaveAttribute("aria-selected", "true"); expect(new URL(page.url()).searchParams.get("followMode")).toBe("waiting-update"); await page.goBack(); await expect(page.getByRole("tab", { name: language === "en" ? "Documents" : "Dokumenter", exact: true })).toHaveAttribute("aria-selected", "true"); expect(new URL(page.url()).searchParams.get("section")).toBe("documents"); await page.goForward(); await expect(analysisTab).toHaveAttribute("aria-selected", "true"); await testInfo.attach(`workspace-native-navigation-${language}-${theme}`, { body: await page.screenshot({ fullPage: true }), contentType: "image/png" }); }); } } test("[PRES-S03 PRES-W11] responsive settings navigation keeps rule edits and persists notification preferences", async ({ page, headers }) => { expect((await page.request.get(`${apiUrl}/auth/me`, { headers })).status()).toBe(200); await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/settings"); await page.getByLabel("Settings section", { exact: true }).click(); await page.getByRole("option", { name: "Follow-ups", exact: true }).click(); const field = page.getByLabel("Applied: follow-up days", { exact: true }); await field.fill("5"); await page.setViewportSize({ width: 1440, height: 900 }); await expect(field).toHaveValue("5"); const saveRules = page.waitForResponse(response => response.url() === `${apiUrl}/rules` && response.request().method() === 'PUT'); await page.getByRole("button", { name: "Save Rules", exact: true }).click(); expect((await saveRules).status()).toBe(204); await expect.poll(async () => (await (await page.request.get(`${apiUrl}/rules`)).json()).appliedFollowUpDays).toBe(5); await page.getByRole("tab", { name: "Notifications", exact: true }).click(); await page.getByLabel("Email reminders for follow-ups", { exact: true }).uncheck(); // Read back after the mutation completes; concurrent read/write stress is a // separate SQLite defect (P4-04-O02), not this sequential UI preservation flow. const saveNotifications = page.waitForResponse(response => response.url() === `${apiUrl}/notification-settings` && response.request().method() === 'PUT'); await page.getByRole("button", { name: "Save notification settings", exact: true }).click(); expect((await saveNotifications).status()).toBe(200); await expect.poll(async () => (await (await page.request.get(`${apiUrl}/notification-settings`)).json()).emailFollowUpRemindersEnabled).toBe(false); await page.reload(); await page.getByRole("tab", { name: "Notifications", exact: true }).click(); await expect(page.getByLabel("Email reminders for follow-ups", { exact: true })).not.toBeChecked(); }); test("[PRES-S03] rule changes persist through the real API", async ({ page, headers }) => { const settings = await (await page.request.get(`${apiUrl}/rules`)).json(); const saved = await page.request.put(`${apiUrl}/rules`, { headers, data: { ...settings, appliedFollowUpDays: 5 } }); expect(saved.status()).toBe(204); expect((await (await page.request.get(`${apiUrl}/rules`)).json()).appliedFollowUpDays).toBe(5); }); test("[PRES-E07 PRES-W01 PRES-J04] keyboard command navigation and legacy workspace entry preserve the selected job", async ({ page, headers }) => { const companyResponse = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Command AS" } }); expect(companyResponse.status()).toBe(201); const jobResponse = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: (await companyResponse.json()).id, jobTitle: "Command engineer", status: "Saved" } }); expect(jobResponse.ok()).toBe(true); const job = await jobResponse.json(); await page.goto("/dashboard"); await expect(page.getByRole("heading", { name: "Dashboard", exact: true })).toBeVisible(); await page.keyboard.press("Control+k"); await page.getByRole("dialog").getByRole("textbox").fill("Command engineer"); await page.getByText("Command AS - Command engineer", { exact: true }).click(); await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}$`)); await expect(page.getByRole("heading", { name: "Command engineer", exact: true })).toBeVisible(); await page.goto(`/applications/${job.id}?section=match`); await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=match$`)); await expect(page.getByRole("tab", { name: "Match", exact: true })).toHaveAttribute("aria-selected", "true"); await page.keyboard.press("Control+k"); await page.getByRole("dialog").getByText("Add new job", { exact: true }).click(); await expect(page.getByRole("dialog")).toBeVisible(); expect((await (await page.request.get(`${apiUrl}/jobapplications`)).json()).total).toBe(1); }); test("[PRES-A01 PRES-S04 PRES-E07] non-admin navigation cannot grant API privileges or unlock CV AI", async ({ page, headers }) => { await page.goto("/dashboard"); await expect(page.getByRole("heading", { name: "Dashboard", exact: true })).toBeVisible(); for (const name of ["Users", "Audit log", "System"]) await expect(page.getByRole("button", { name, exact: true })).toHaveCount(0); const denied = page.waitForResponse(response => response.url() === `${apiUrl}/users`); await page.goto("/admin/users"); expect((await denied).status()).toBe(403); await expect(page.getByText("e2e@example.test", { exact: true })).toHaveCount(0); expect((await page.request.post(`${apiUrl}/users`, { headers, data: { email: "must-not-exist@example.test", password, roles: ["Admin"] } })).status()).toBe(403); const created = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "Free CV" } }); expect(created.status()).toBe(200); await page.goto(`/career/builder/${(await created.json()).id}`); await page.getByRole("tab", { name: "AI Tools", exact: true }).click(); const actions = page.getByRole("button", { name: "Review suggestion", exact: true }); await expect(actions).toHaveCount(3); for (const action of await actions.all()) await expect(action).toBeDisabled(); expect((await page.request.post(`${apiUrl}/cv/ai/assist`, { headers, data: { action: "improve", text: "Safe test text" } })).status()).toBe(403); expect((await (await page.request.get(`${apiUrl}/auth/me`)).json()).roles).toEqual([]); });