Files
jobtrackingapp/job-tracker-ui/e2e/named-controls-modal-state.spec.ts
T
2026-10-11 10:33:30 +02:00

186 lines
13 KiB
TypeScript

import AxeBuilder from "@axe-core/playwright";
import { expect, test as base, type APIRequestContext, type Page, type TestInfo } from "@playwright/test";
import { translations } from "../src/i18n/translations";
const apiUrl = "http://localhost:5302/api";
const password = "E2ePassword123!";
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(c => c.name === "XSRF-TOKEN")!.value;
return { "X-CSRF-TOKEN": csrf, "X-Session-Context": me.sessionContext as string };
}
const test = base.extend<{ owner: { headers: Record<string, string>; companyId: number; jobId: number; cvId: number } }>({
owner: async ({ playwright, page, context }, provide) => {
const admin = await playwright.request.newContext();
try {
const adminHeaders = await login(admin, "e2e@example.test");
const email = `p402-${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();
});
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { version: "1", contact: { fullName: "Åse Eksempel" }, skills: ["Norsk", "C#"] } } })).status()).toBe(200);
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Nordlys æøå AS" } });
expect(company.status()).toBe(201);
const companyId = (await company.json()).id;
const job = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: "Engineer", status: "Saved" } });
expect(job.ok()).toBe(true);
const jobId = (await job.json()).id;
const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "Named controls CV", jobApplicationId: jobId } });
expect(cv.status()).toBe(200);
const cvId = (await cv.json()).id;
expect((await page.request.put(`${apiUrl}/jobapplications/${jobId}/cv`, { headers, data: { variantId: cvId } })).status()).toBe(200);
expect((await page.request.post(`${apiUrl}/attachments`, { headers, multipart: { jobId: String(jobId), files: { name: "evidence.txt", mimeType: "text/plain", buffer: Buffer.from("Synthetic æøå\n") } } })).ok()).toBe(true);
await provide({ headers, companyId, jobId, cvId });
} finally { await admin.dispose(); }
},
});
async function capture(page: Page, info: TestInfo, name: string) {
const path = process.env.P4_02_CAPTURE === "true" ? `../docs/redesign/phase-4/p4-02/visual/${name}.png` : info.outputPath(`${name}.png`);
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await page.screenshot({ path });
await info.attach(name, { path, contentType: "image/png" });
}
async function scan(page: Page, info: TestInfo, name: string, dialog = false) {
let axe = new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]);
if (dialog) axe = axe.include('[role="dialog"]');
const result = await axe.analyze();
await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2), contentType: "application/json" });
// P5-00 repairs the command-list boundary; dialogs now gate every rule.
// Loaded pages still gate naming and inherited contrast.
// Preview scrolling and wider workspace structure remain separately recorded debt.
expect(dialog ? result.violations : result.violations.filter(v => ["label", "select-name", "aria-input-field-name", "aria-dialog-name", "color-contrast"].includes(v.id))).toEqual([]);
}
for (const [mode, language, width] of [["light", "en", 1440], ["dark", "nb", 1440], ["light", "nb", 390], ["dark", "en", 390]] as const) {
test(`[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] ${mode}/${language}/${width} named controls, modal keyboard and retained draft`, async ({ page, owner }, info) => {
test.setTimeout(120_000);
const t = translations[language];
const suffix = `${mode}-${language}-${width}`;
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.addInitScript(({ mode, language }) => { localStorage.setItem("jobtracker.themeMode", mode); localStorage.setItem("uiLanguage", language); }, { mode, language });
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).ok()).toBe(true);
await page.goto("/companies");
const status = page.locator("main").getByRole("status");
await expect(status).toHaveText(t.contentLoaded);
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode);
const edit = page.getByRole("button", { name: `${t.jobTableEdit}: Nordlys æøå AS`, exact: true });
await edit.focus();
await page.keyboard.press("Control+k");
const commands = page.getByRole("dialog", { name: t.quickSearch, exact: true });
await expect(commands.getByRole("textbox", { name: t.quickSearch })).toBeFocused();
await page.keyboard.press("Tab");
await expect(commands.getByRole("button").first()).toBeFocused();
await scan(page, info, "command", true);
await capture(page, info, `command-${suffix}`);
await page.keyboard.press("Escape");
await expect(edit).toBeFocused();
const statusNode = await status.elementHandle();
await statusNode!.evaluate(node => {
const observer = new MutationObserver(records => { node.setAttribute("data-test-announcements", String(Number(node.getAttribute("data-test-announcements") || 0) + records.length)); });
observer.observe(node, { childList: true, characterData: true, subtree: true });
});
await edit.press("Enter");
const companyDialog = page.getByRole("dialog", { name: t.companiesEdit });
await companyDialog.getByRole("textbox", { name: t.companiesRecruiterName, exact: true }).fill("Retained æøå draft");
await page.route(`${apiUrl}/companies/${owner.companyId}`, route => route.request().method() === "PUT" ? route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic save failure" }) }) : route.continue());
await companyDialog.getByRole("button", { name: t.save, exact: true }).click();
// P6 closes P4-02-D02: the retained draft's error belongs to the dialog.
const saveError = companyDialog.getByRole("alert");
await expect(saveError).toContainText("Synthetic save failure");
await expect(saveError).toBeVisible();
expect(await saveError.evaluate(node => !!node.closest('[aria-hidden="true"]'))).toBe(false);
await expect(companyDialog.getByRole("textbox", { name: t.companiesRecruiterName, exact: true })).toHaveValue("Retained æøå draft");
expect(await statusNode!.getAttribute("data-test-announcements")).toBeNull();
await capture(page, info, `retained-draft-${suffix}`);
await page.keyboard.press("Escape");
await expect(edit).toBeFocused();
expect((await (await page.request.get(`${apiUrl}/companies/${owner.companyId}`)).json()).recruiterName).toBeFalsy();
const canonicalBefore = (await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile;
await page.goto(`/career/builder/${owner.cvId}`);
await page.getByRole("tab", { name: t.cvEditorCustomize, exact: true }).click();
const advanced = page.getByRole("button", { name: t.cvEditorAdvancedSettings, exact: true });
await advanced.focus(); await page.keyboard.press("Enter");
await expect(advanced).toHaveAttribute("aria-expanded", "true");
for (const name of [t.cvEditorHeadingFont, t.cvEditorBodyFont, t.cvEditorHeadingTreatment, t.cvEditorHeaderTreatment]) await expect(page.getByRole("combobox", { name, exact: true })).toBeVisible();
const heading = page.getByRole("combobox", { name: new RegExp(t.cvEditorHeadingFont) });
await heading.focus();
await heading.press("Enter");
await page.getByRole("option", { name: "Arial", exact: true }).focus();
await page.keyboard.press("Enter");
await expect(heading).toBeFocused();
await scan(page, info, "cv-controls");
await capture(page, info, `cv-select-${suffix}`);
await page.getByRole("combobox", { name: t.cvEditorHeaderTreatment, exact: true }).scrollIntoViewIfNeeded();
await capture(page, info, `cv-treatments-${suffix}`);
// The existing autosave may finish during the loaded scan; do not click a
// now-disabled Save button or change autosave timing merely for this test.
await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible();
expect((await (await page.request.get(`${apiUrl}/cv/variants/${owner.cvId}`)).json()).settings.headingFont).toBe("Arial, Helvetica, sans-serif");
expect((await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile).toEqual(canonicalBefore);
await page.goto(`/jobs/${owner.jobId}?section=cover-letter`);
for (const name of [t.coverAiAction, t.coverAiTone, t.coverAiDocumentLanguage]) await expect(page.getByRole("combobox", { name: new RegExp(name) })).toBeVisible();
await scan(page, info, "cover-controls");
await page.getByRole("combobox", { name: new RegExp(t.coverAiAction) }).scrollIntoViewIfNeeded();
await capture(page, info, `cover-controls-${suffix}`);
await page.goto(`/jobs/${owner.jobId}?section=overview`);
await page.getByRole("tab", { name: t.workspaceDocuments, exact: true }).click();
const purpose = page.getByRole("combobox", { name: new RegExp(t.attachmentsPurpose) });
await purpose.focus();
await purpose.press("Enter");
await page.getByRole("option", { name: t.attachmentsPurposeCertificate, exact: true }).focus();
await page.keyboard.press("Enter");
await expect(purpose).toBeFocused();
await expect(page.getByRole("switch", { name: `${t.attachmentsAiUse}: evidence.txt` })).toBeVisible();
const rename = page.getByRole("button", { name: `${t.attachmentsRename}: evidence.txt`, exact: true });
await rename.focus();
await rename.press("Enter");
const prompt = page.getByRole("dialog", { name: t.attachmentsRenameTitle });
await expect(prompt).toHaveAccessibleDescription(t.attachmentsRenamePrompt);
const filename = prompt.getByRole("textbox", { name: t.attachmentsRenamePrompt });
await expect(filename).toBeFocused();
await expect(filename).toHaveAccessibleDescription("12/180");
await filename.fill("Reviewed æøå.txt");
await filename.press("Shift+Tab");
await expect(prompt.getByRole("button", { name: t.attachmentsRename, exact: true })).toBeFocused();
await page.keyboard.press("Tab");
await expect(filename).toBeFocused();
await scan(page, info, "prompt", true);
await capture(page, info, `prompt-${suffix}`);
await page.keyboard.press("Escape");
await expect(rename).toBeFocused();
await rename.press("Enter");
await expect(filename).toHaveValue("evidence.txt");
await filename.fill("Reviewed æøå.txt");
await prompt.getByRole("button", { name: t.attachmentsRename, exact: true }).press("Enter");
const remove = page.getByRole("button", { name: `${t.attachmentsDelete}: Reviewed æøå.txt`, exact: true });
await remove.focus();
await remove.press("Enter");
const confirm = page.getByRole("dialog", { name: t.attachmentsDeleteTitle });
const cancel = confirm.getByRole("button", { name: t.cancel, exact: true });
await expect(cancel).toBeFocused();
await expect(confirm).toHaveAccessibleDescription(t.attachmentsDeleteConfirm.replace("{name}", "Reviewed æøå.txt"));
await cancel.press("Shift+Tab");
await expect(confirm.getByRole("button", { name: t.attachmentsDelete, exact: true })).toBeFocused();
await page.keyboard.press("Tab");
await expect(cancel).toBeFocused();
await scan(page, info, "confirm", true);
await capture(page, info, `confirm-${suffix}`);
await cancel.press("Enter");
await expect(remove).toBeFocused();
const attachments = await (await page.request.get(`${apiUrl}/attachments/${owner.jobId}`)).json();
expect(attachments).toHaveLength(1);
expect(attachments[0]).toMatchObject({ fileName: "Reviewed æøå.txt", purpose: "certificate" });
});
}