244 lines
14 KiB
TypeScript
244 lines
14 KiB
TypeScript
import AxeBuilder from "@axe-core/playwright";
|
|
import { expect, test as base, type APIRequestContext, type Locator, 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(cookie => cookie.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; 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 = `p4-${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);
|
|
// Real disposable API, no provider calls. Only the explicit company-state case
|
|
// below delays/replaces a GET to make loading/error/empty deterministic.
|
|
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();
|
|
});
|
|
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Nordlys æøå AS", location: "Tromsø" } });
|
|
expect(company.status()).toBe(201);
|
|
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: {
|
|
version: "1", contact: { fullName: "Åse Eksempel", headline: "Engineer" },
|
|
jobs: [{ title: "Engineer", company: "Nordlys AS", bullets: ["Built reliable APIs"] }], skills: ["C#", "Norsk"],
|
|
} } })).status()).toBe(200);
|
|
const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "P4 loaded CV" } });
|
|
expect(cv.status()).toBe(200);
|
|
await provide({ headers, companyId: (await company.json()).id, cvId: (await cv.json()).id });
|
|
} finally { await admin.dispose(); }
|
|
},
|
|
});
|
|
|
|
async function setup(page: Page, mode: "light" | "dark", language: "en" | "nb", width: number) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.addInitScript(({ mode, language }) => {
|
|
localStorage.setItem("jobtracker.themeMode", mode);
|
|
localStorage.setItem("uiLanguage", language);
|
|
}, { mode, language });
|
|
}
|
|
|
|
async function capture(page: Page, info: TestInfo, name: string) {
|
|
const path = process.env.P4_CAPTURE === "true"
|
|
? `../docs/redesign/phase-4/p4-01/visual/${name}.png` : info.outputPath(`${name}.png`);
|
|
// A post-axe capture can precede Chromium repainting unchanged sidebar text.
|
|
// Allow a complete paint cycle; do not alter the page or hide content for capture.
|
|
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
|
await page.screenshot({ path, fullPage: false });
|
|
await info.attach(name, { path, contentType: "image/png" });
|
|
}
|
|
|
|
async function loadedContrast(page: Page, info: TestInfo, state: string) {
|
|
// Scan the loaded state after finite entry/focus transitions finish. Sampling
|
|
// MUI's Fade/Grow mid-frame measures composited, partially transparent text.
|
|
// Keep normal motion enabled, retain every axe rule and record what we waited for.
|
|
const transitions = await page.evaluate(async () => {
|
|
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
const active = document.getAnimations().filter(animation =>
|
|
animation.playState === "running" && Number.isFinite(animation.effect?.getComputedTiming().endTime));
|
|
const evidence = active.map(animation => {
|
|
const target = (animation.effect as KeyframeEffect | null)?.target;
|
|
return { type: animation.constructor.name, opacity: target instanceof Element ? getComputedStyle(target).opacity : null };
|
|
});
|
|
await Promise.all(active.map(animation => animation.finished.catch(() => undefined)));
|
|
return evidence;
|
|
});
|
|
await info.attach(`${state}-transitions`, { body: JSON.stringify(transitions), contentType: "application/json" });
|
|
const result = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();
|
|
// This slice gates color contrast. Preserve the full scan (including naming and
|
|
// preview-scroll findings) for P4-02/03; this is not a full accessibility pass.
|
|
await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2), contentType: "application/json" });
|
|
expect(result.violations.filter(v => v.id === "color-contrast"), state).toEqual([]);
|
|
}
|
|
|
|
async function keyboardFocus(page: Page, target: Locator) {
|
|
await target.focus();
|
|
const option = await target.getAttribute("role") === "option";
|
|
await page.keyboard.press(option ? "ArrowDown" : "Tab");
|
|
await page.keyboard.press(option ? "ArrowUp" : "Shift+Tab");
|
|
await expect(target).toBeFocused();
|
|
const ring = await target.evaluate(node => {
|
|
const control = node.closest(".MuiInputBase-root") ?? node;
|
|
const notch = control.querySelector(".MuiOutlinedInput-notchedOutline");
|
|
const css = getComputedStyle(notch ?? control);
|
|
const color = notch ? css.borderTopColor : css.outlineColor;
|
|
const width = notch ? css.borderTopWidth : css.outlineWidth;
|
|
const style = notch ? css.borderTopStyle : css.outlineStyle;
|
|
const offset = notch ? "-3px" : css.outlineOffset;
|
|
const rgba = (value: string) => value.match(/[\d.]+/g)!.map(Number);
|
|
const blend = (front: number[], back: number[]) => front.slice(0, 3).map((v, i) => v * (front[3] ?? 1) + back[i] * (1 - (front[3] ?? 1)));
|
|
const chain: Element[] = [];
|
|
// Inset rings sit on the control; offset rings sit on its parent surface.
|
|
for (let el = Number.parseFloat(offset) < 0 ? control : control.parentElement; el; el = el.parentElement) chain.unshift(el);
|
|
const surface = chain.reduce((back, el) => blend(rgba(getComputedStyle(el).backgroundColor), back), [255, 255, 255]);
|
|
const luminance = (rgb: number[]) => rgb.map(v => v / 255).map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4).reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0);
|
|
const a = luminance(blend(rgba(color), surface));
|
|
const b = luminance(surface);
|
|
return { width, style, color, offset, contrast: (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) };
|
|
});
|
|
expect(ring.width).toBe("3px");
|
|
expect(ring.style).toBe("solid");
|
|
expect(ring.color).not.toBe("rgba(0, 0, 0, 0)");
|
|
expect(ring.contrast).toBeGreaterThanOrEqual(3);
|
|
return ring;
|
|
}
|
|
|
|
for (const mode of ["light", "dark"] as const) {
|
|
for (const language of ["en", "nb"] as const) {
|
|
for (const width of [1440, 390]) {
|
|
test(`[PRES-E08 PRES-J08] ${mode}/${language}/${width} loading → error → retry → empty → populated`, async ({ page, owner }, info) => {
|
|
const t = translations[language];
|
|
await setup(page, mode, language, width);
|
|
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).ok()).toBe(true);
|
|
let release!: () => void;
|
|
const delayed = new Promise<void>(resolve => { release = resolve; });
|
|
let phase: "error" | "empty" | "populated" = "error";
|
|
await page.route(`${apiUrl}/companies`, async route => {
|
|
await delayed;
|
|
if (phase === "populated") await route.continue();
|
|
else await route.fulfill({ status: phase === "error" ? 503 : 200, contentType: "application/json", body: phase === "error" ? JSON.stringify({ message: t.companiesLoadFailed }) : "[]" });
|
|
});
|
|
await page.goto("/companies");
|
|
await expect(page.locator("main").getByRole("status")).toHaveAttribute("aria-live", "polite");
|
|
await expect(page.locator("main").getByRole("progressbar")).toHaveAccessibleName(t.loading);
|
|
release();
|
|
await expect(page.locator("main").getByRole("alert")).toContainText(t.companiesLoadFailed);
|
|
await loadedContrast(page, info, "loaded-error");
|
|
const retry = page.getByRole("button", { name: t.retry, exact: true });
|
|
await keyboardFocus(page, retry);
|
|
phase = "empty";
|
|
await retry.press("Enter");
|
|
await expect(page.getByText(t.companiesEmpty, { exact: true })).toBeVisible();
|
|
await expect(page.locator("main").getByRole("alert")).toHaveCount(0);
|
|
phase = "populated";
|
|
await page.reload();
|
|
await expect(page.locator("main").getByText("Nordlys æøå AS", { exact: true })).toBeVisible();
|
|
await expect(page.locator("main").getByRole("status")).toHaveText(t.contentLoaded);
|
|
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode);
|
|
await loadedContrast(page, info, "loaded-company");
|
|
// Both desktop table and phone cards retain named actions and a real dialog.
|
|
const edit = page.getByRole("button", { name: `${t.jobTableEdit}: Nordlys æøå AS`, exact: true });
|
|
await keyboardFocus(page, edit);
|
|
await capture(page, info, `companies-${mode}-${language}-${width}`);
|
|
await edit.press("Enter");
|
|
const dialog = page.getByRole("dialog");
|
|
await expect(dialog).toHaveAccessibleName(t.companiesEdit);
|
|
const field = dialog.getByRole("textbox").first();
|
|
await keyboardFocus(page, field);
|
|
await loadedContrast(page, info, "loaded-company-dialog");
|
|
await page.keyboard.press("Escape");
|
|
await expect(edit).toBeFocused();
|
|
if (width === 390) {
|
|
const trigger = page.getByRole("button", { name: t.openNavigation, exact: true });
|
|
await keyboardFocus(page, trigger);
|
|
await trigger.press("Enter");
|
|
const destination = page.getByRole("button", { name: t.companies, exact: true });
|
|
await keyboardFocus(page, destination);
|
|
await page.keyboard.press("Escape");
|
|
await expect(trigger).toBeFocused();
|
|
}
|
|
expect((await page.request.get(`${apiUrl}/companies/${owner.companyId}`)).status()).toBe(200);
|
|
});
|
|
}
|
|
}
|
|
|
|
test(`[PRES-E07 PRES-E08 PRES-J05 PRES-J07] ${mode} loaded status semantics, navigation and command focus`, async ({ page, owner }, info) => {
|
|
await setup(page, mode, "en", 1440);
|
|
for (const status of ["Saved", "Applied", "Waiting", "Interview", "Offer", "Rejected"]) {
|
|
const created = await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId: owner.companyId, jobTitle: `P4 ${status}`, status } });
|
|
expect(created.ok()).toBe(true);
|
|
}
|
|
await page.goto("/jobs");
|
|
await expect(page.getByRole("table").getByText("P4 Offer", { exact: true })).toBeVisible();
|
|
await loadedContrast(page, info, "loaded-jobs");
|
|
const nav = page.getByRole("button", { name: "Companies", exact: true });
|
|
await keyboardFocus(page, nav);
|
|
await capture(page, info, `jobs-nav-${mode}`);
|
|
await page.keyboard.press("Control+k");
|
|
const dialog = page.getByRole("dialog");
|
|
await expect(dialog).toBeVisible();
|
|
await keyboardFocus(page, dialog.getByRole("textbox"));
|
|
await page.keyboard.press("Tab");
|
|
const command = dialog.getByRole("button").first();
|
|
await keyboardFocus(page, command);
|
|
await page.keyboard.press("Escape");
|
|
await expect(dialog).not.toBeVisible();
|
|
});
|
|
|
|
test(`[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] ${mode} loaded CV tabs, selects and settings`, async ({ page, owner }, info) => {
|
|
await setup(page, mode, "en", 1440);
|
|
await page.goto(`/career/builder/${owner.cvId}`);
|
|
await expect(page.getByLabel("CV name", { exact: true })).toHaveValue("P4 loaded CV");
|
|
await expect(page.getByTitle("CV preview page 1")).toBeVisible();
|
|
const download = page.getByRole("button", { name: translations.en.cvEditorDownloadPdf, exact: true });
|
|
await download.hover();
|
|
await loadedContrast(page, info, "loaded-cv-template-button-hover");
|
|
await page.getByRole("tab", { name: "Content", exact: true }).click();
|
|
await expect(page.getByLabel("Headline override", { exact: true })).toBeVisible();
|
|
await keyboardFocus(page, page.getByLabel("Headline override", { exact: true }));
|
|
await loadedContrast(page, info, "loaded-cv-content");
|
|
await capture(page, info, `cv-content-${mode}`);
|
|
const customize = page.getByRole("tab", { name: "Customize", exact: true });
|
|
await page.getByRole("tab", { name: "Content", exact: true }).focus();
|
|
await page.keyboard.press("ArrowRight");
|
|
await expect(customize).toBeFocused();
|
|
await customize.press("Enter");
|
|
await keyboardFocus(page, customize);
|
|
const select = page.getByRole("combobox", { name: "Page size", exact: true });
|
|
await keyboardFocus(page, select);
|
|
await select.press("Enter");
|
|
const option = page.getByRole("option", { name: "A4", exact: true });
|
|
await expect(option).toBeFocused();
|
|
await keyboardFocus(page, option);
|
|
await page.keyboard.press("Escape");
|
|
await expect(select).toBeFocused();
|
|
await loadedContrast(page, info, "loaded-cv-customize");
|
|
await page.getByRole("tab", { name: "AI Tools", exact: true }).click();
|
|
const aiReviews = page.getByRole("button", { name: translations.en.cvDocumentAiReview, exact: true });
|
|
await expect(aiReviews).toHaveCount(3);
|
|
for (const review of await aiReviews.all()) await expect(review).toBeDisabled();
|
|
await loadedContrast(page, info, "loaded-cv-ai-locked");
|
|
await page.getByRole("tab", { name: "History", exact: true }).click();
|
|
await loadedContrast(page, info, "loaded-cv-history");
|
|
await page.goto("/settings");
|
|
await page.getByRole("tab", { name: "Notifications", exact: true }).click();
|
|
await expect(page.getByLabel("Email reminders for follow-ups", { exact: true })).toBeVisible();
|
|
const save = page.getByRole("button", { name: "Save notification settings", exact: true });
|
|
await save.click();
|
|
// Ordinary pointer activation doesn't leave a keyboard ring on a button.
|
|
await expect(save).not.toHaveClass(/Mui-focusVisible/);
|
|
expect(await save.evaluate(node => node.matches(":focus-visible"))).toBe(false);
|
|
await keyboardFocus(page, save);
|
|
await loadedContrast(page, info, "loaded-settings");
|
|
});
|
|
}
|