248 lines
16 KiB
TypeScript
248 lines
16 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(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>; ids: number[] } }>({
|
|
owner: async ({ playwright, page, context }, provide) => {
|
|
const admin = await playwright.request.newContext();
|
|
try {
|
|
const adminHeaders = await login(admin, "e2e@example.test");
|
|
const email = `p405-${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();
|
|
});
|
|
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Nordlys æøå AS", location: "Tromsø" } });
|
|
expect(company.status()).toBe(201);
|
|
const companyId = (await company.json()).id;
|
|
const ids: number[] = [];
|
|
for (let index = 0; index < 18; index++) {
|
|
const result = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: {
|
|
companyId, jobTitle: `Density ${String(index).padStart(2, "0")}${index === 0 ? " — Seniorrådgiver for plattformutvikling og digitale fellestjenester" : " Engineer"}`,
|
|
status: index === 0 ? "Avventer faglig vurdering" : index % 3 === 0 ? "Interview" : "Saved",
|
|
dateApplied: "2026-08-01", location: "Tromsø / hjemmekontor", jobUrl: "https://example.test/listing",
|
|
} });
|
|
expect(result.status()).toBe(201);
|
|
ids.push((await result.json()).id);
|
|
}
|
|
await provide({ headers, ids });
|
|
} finally { await admin.dispose(); }
|
|
},
|
|
});
|
|
|
|
async function capture(page: Page, info: TestInfo, name: string) {
|
|
const path = process.env.P4_05_CAPTURE === "true" ? `../docs/redesign/phase-4/p4-05/visual/${name}.png` : info.outputPath(`${name}.png`);
|
|
await page.screenshot({ path, fullPage: false });
|
|
await info.attach(name, { path, contentType: "image/png" });
|
|
}
|
|
async function scan(page: Page, info: TestInfo, name: string) {
|
|
const result = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();
|
|
await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: "application/json" });
|
|
expect(result.violations).toEqual([]);
|
|
}
|
|
async function keyboardFocus(page: Page, target: Locator) {
|
|
await target.focus();
|
|
await page.keyboard.press("Tab");
|
|
await page.keyboard.press("Shift+Tab");
|
|
await expect(target).toBeFocused();
|
|
// The native input can be focused before MUI commits the wrapper's
|
|
// Mui-focusVisible class. Require the same ring within the normal UI timeout.
|
|
const readRing = () => target.evaluate(node => {
|
|
const control = node.closest(".MuiInputBase-root, .MuiCheckbox-root") ?? node;
|
|
const notch = control.querySelector(".MuiOutlinedInput-notchedOutline");
|
|
const css = getComputedStyle(notch ?? control);
|
|
return { width: notch ? css.borderTopWidth : css.outlineWidth, style: notch ? css.borderTopStyle : css.outlineStyle };
|
|
});
|
|
await expect.poll(readRing).toEqual({ width: "3px", style: "solid" });
|
|
}
|
|
|
|
for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb"] as const) {
|
|
test(`[PRES-J05 J06 W01 W02 S03 E08 E09] ${mode}/${language} table and cards preserve behavior at both densities`, async ({ page, owner }, info) => {
|
|
test.setTimeout(180_000);
|
|
const t = translations[language];
|
|
const suffix = `${mode}-${language}`;
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).status()).toBe(204);
|
|
await page.addInitScript(({ mode, language }) => {
|
|
localStorage.setItem("jobtracker.themeMode", mode); localStorage.setItem("uiLanguage", language);
|
|
localStorage.setItem("jobColumns", JSON.stringify({ status: true, dateApplied: true, daysSince: true, jobUrl: true }));
|
|
localStorage.setItem("jobPageSize", "15");
|
|
}, { mode, language });
|
|
await page.setViewportSize({ width: 1440, height: 960 });
|
|
await page.goto("/jobs?q=Density&sortBy=jobTitle&sortDir=asc");
|
|
const main = page.getByRole("main");
|
|
const density = main.getByRole("combobox", { name: t.listDensity, exact: true });
|
|
const first = main.locator(`[data-job-row-id='${owner.ids[0]}']`);
|
|
const ordinary = main.locator(`[data-job-row-id='${owner.ids[1]}']`);
|
|
const region = main.getByRole("region", { name: t.jobTableScrollRegion });
|
|
await expect(first).toBeVisible();
|
|
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode);
|
|
await expect(density).toHaveText(t.densityComfortable);
|
|
await expect(main.getByRole("columnheader", { name: t.jobTableRole, exact: true })).toHaveAttribute("aria-sort", "ascending");
|
|
await capture(page, info, `jobs-top-desktop-${suffix}`);
|
|
const rowText = await first.innerText();
|
|
const headers = await main.getByRole("columnheader").allTextContents();
|
|
const select = first.getByRole("checkbox");
|
|
await select.check();
|
|
const query = new URL(page.url()).search;
|
|
const rowHeights: number[] = [];
|
|
let listingReads = 0;
|
|
page.on("request", request => { if (new URL(request.url()).pathname === "/api/jobapplications" && request.method() === "GET") listingReads++; });
|
|
const chooseDensity = async (value: string) => {
|
|
await density.focus(); await density.press("Enter");
|
|
await page.getByRole("option", { name: value, exact: true }).focus(); await page.keyboard.press("Enter");
|
|
await expect(density).toBeFocused();
|
|
};
|
|
for (const value of ["comfortable", "compact"] as const) {
|
|
if (value === "compact") await chooseDensity(t.densityCompact);
|
|
await expect(select).toBeChecked();
|
|
expect(await first.innerText()).toBe(rowText);
|
|
expect(await main.getByRole("columnheader").allTextContents()).toEqual(headers);
|
|
expect(new URL(page.url()).search).toBe(query);
|
|
expect(listingReads).toBe(0);
|
|
rowHeights.push((await ordinary.boundingBox())!.height);
|
|
await keyboardFocus(page, select);
|
|
await keyboardFocus(page, region);
|
|
await page.keyboard.press("ArrowRight");
|
|
await expect.poll(() => region.evaluate(node => node.scrollLeft)).toBeGreaterThan(0);
|
|
await region.evaluate(node => { node.scrollLeft = 0; });
|
|
await density.scrollIntoViewIfNeeded();
|
|
await capture(page, info, `desktop-${value}-${suffix}`);
|
|
await scan(page, info, `desktop-${value}-${suffix}`);
|
|
}
|
|
expect(rowHeights[0] - rowHeights[1]).toBeCloseTo(8, 0);
|
|
await info.attach(`row-heights-${suffix}`, { body: JSON.stringify({ comfortable: rowHeights[0], compact: rowHeights[1] }), contentType: "application/json" });
|
|
|
|
// Responsive cards retain selected IDs; targets do not shrink with density.
|
|
for (const value of ["comfortable", "compact"] as const) {
|
|
await chooseDensity(value === "compact" ? t.densityCompact : t.densityComfortable);
|
|
for (const width of [1024, 768, 390, 320]) {
|
|
await page.setViewportSize({ width, height: 960 });
|
|
await expect(first.getByRole("checkbox")).toBeChecked();
|
|
await expect(first.getByText("Avventer faglig vurdering", { exact: true })).toBeVisible();
|
|
if (width >= 768) { await expect(main.getByRole("table")).toBeVisible(); continue; }
|
|
await expect(main.getByRole("table")).toHaveCount(0);
|
|
if (width === 390 && value === "comfortable") {
|
|
await page.evaluate(() => window.scrollTo(0, 0));
|
|
await capture(page, info, `jobs-top-phone-${suffix}`);
|
|
await main.getByRole("button", { name: t.jobTableStatus, exact: true }).click();
|
|
await expect(page.getByRole("menuitem", { name: t.statusOffer, exact: true })).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
}
|
|
// Geometric containment alone misses a title squeezed into one-letter lines.
|
|
expect((await first.getByText(/^Density 00/).boundingBox())!.width).toBeGreaterThanOrEqual(150);
|
|
const metrics = await first.evaluate(card => Array.from(card.querySelectorAll("button,a,.MuiCheckbox-root")).map(node => {
|
|
const rect = node.getBoundingClientRect(); return { text: node.textContent, width: rect.width, height: rect.height, left: rect.left, right: rect.right };
|
|
}));
|
|
expect(metrics.every(rect => rect.height >= 44 && rect.width >= 44 && rect.left >= 0 && rect.right <= width)).toBe(true);
|
|
const overflow = await first.evaluate(card => Array.from(card.querySelectorAll("p,span,button,a")).filter(node => node.getClientRects().length).map(node => node.getBoundingClientRect()).some(rect => rect.left < 0 || rect.right > innerWidth + 1));
|
|
expect(overflow).toBe(false);
|
|
await first.scrollIntoViewIfNeeded();
|
|
await capture(page, info, `phone-${width}-${value}-${suffix}`);
|
|
if (width === 390) await scan(page, info, `phone-${value}-${suffix}`);
|
|
}
|
|
await page.setViewportSize({ width: 1440, height: 960 });
|
|
}
|
|
|
|
// Native row status request, listing-link isolation and workspace navigation.
|
|
await select.uncheck();
|
|
// Match by the translated action prefix; the long title remains in its accessible name.
|
|
const statusAction = first.getByRole("button", { name: new RegExp(`^${t.jobTableQuickStatus}:`) });
|
|
await keyboardFocus(page, statusAction);
|
|
await page.keyboard.press("Escape"); // Dismiss the existing tooltip, preserving focus.
|
|
const targets = await first.locator(".MuiIconButton-root,.MuiCheckbox-root").evaluateAll(nodes => nodes.map(node => ({ width: node.getBoundingClientRect().width, height: node.getBoundingClientRect().height })));
|
|
expect(targets.every(target => target.width >= 32 && target.height >= 32)).toBe(true);
|
|
await capture(page, info, `row-action-focus-${suffix}`);
|
|
await statusAction.press("Enter");
|
|
const changed = page.waitForResponse(r => r.url() === `${apiUrl}/jobapplications/${owner.ids[0]}/status` && r.request().method() === "PATCH");
|
|
await page.getByRole("menuitem", { name: t.jobTableSetStatus.replace("{status}", t.statusInterview), exact: true }).click();
|
|
expect((await changed).status()).toBe(204);
|
|
await expect(first.getByText(t.statusInterview, { exact: true })).toBeVisible();
|
|
expect(new URL(page.url()).pathname).toBe("/jobs");
|
|
const listing = first.getByRole("link", { name: t.jobTableLink, exact: true });
|
|
const popup = page.waitForEvent("popup");
|
|
await listing.click();
|
|
await (await popup).close();
|
|
expect(new URL(page.url()).pathname).toBe("/jobs");
|
|
await first.focus(); await first.press("Enter");
|
|
await expect(page).toHaveURL(new RegExp(`/jobs/${owner.ids[0]}$`));
|
|
await expect(page.getByRole("heading", { name: /^Density 00/ })).toBeVisible();
|
|
await page.goBack();
|
|
// Browser Back restores the originating row and query after the workspace unmounts.
|
|
expect(new URL(page.url()).search).toBe(query);
|
|
await expect(density).toHaveText(t.densityComfortable);
|
|
await expect(first).toBeFocused();
|
|
// The explicit workspace Back action retains the same focus contract.
|
|
await first.focus(); await first.press("Enter");
|
|
await expect(page.getByRole("heading", { name: /^Density 00/ })).toBeVisible();
|
|
await page.getByRole("button", { name: t.workspaceBack, exact: true }).click();
|
|
await expect(page).toHaveURL(url => url.pathname === "/jobs" && url.search === query);
|
|
await expect(first).toBeFocused();
|
|
await ordinary.focus(); await ordinary.press("Enter");
|
|
await expect(page).toHaveURL(new RegExp(`/jobs/${owner.ids[1]}$`));
|
|
await page.goBack();
|
|
await expect(ordinary).toBeFocused();
|
|
|
|
await main.getByRole("button", { name: t.tableNextPage }).click();
|
|
await expect(main.locator("[data-job-row-id]")).toHaveCount(3);
|
|
await chooseDensity(t.densityCompact);
|
|
expect(new URL(page.url()).searchParams.get("page")).toBe("2");
|
|
await main.getByRole("combobox", { name: t.settingsRowsPerPage }).click();
|
|
await page.getByRole("option", { name: "20", exact: true }).click();
|
|
await expect(main.locator("[data-job-row-id]")).toHaveCount(18);
|
|
await expect(density).toHaveText(t.densityCompact);
|
|
expect(await page.evaluate(() => localStorage.getItem("jobPageSize"))).toBe("20");
|
|
|
|
await main.getByRole("button", { name: t.jobTableColumns, exact: true }).click();
|
|
await page.getByRole("menuitem", { name: t.settingsColumnJobUrl, exact: true }).click();
|
|
await page.keyboard.press("Escape");
|
|
await expect(first.getByRole("link", { name: t.jobTableLink, exact: true })).toHaveCount(0);
|
|
expect(JSON.parse((await page.evaluate(() => localStorage.getItem("jobColumns")))!).jobUrl).toBe(false);
|
|
|
|
// Real confirm/soft-delete and the same renderer's existing Trash restore path.
|
|
await first.getByRole("button", { name: new RegExp(`^${t.jobTableSoftDelete}:`) }).click();
|
|
const deleted = page.waitForResponse(r => r.url() === `${apiUrl}/jobapplications/${owner.ids[0]}` && r.request().method() === "DELETE");
|
|
await page.getByRole("dialog").getByRole("button", { name: t.jobTableMove, exact: true }).click();
|
|
expect((await deleted).status()).toBe(204);
|
|
await expect(first).toHaveCount(0);
|
|
await page.goto("/trash");
|
|
await expect(first).toBeVisible();
|
|
await chooseDensity(t.densityCompact);
|
|
await first.getByRole("checkbox").check();
|
|
const restored = page.waitForResponse(r => r.url() === `${apiUrl}/jobapplications/${owner.ids[0]}/restore` && r.request().method() === "POST");
|
|
await main.getByRole("button", { name: t.jobTableRestoreSelected, exact: true }).click();
|
|
expect((await restored).status()).toBe(204);
|
|
await expect(first).toHaveCount(0);
|
|
await expect(density).toHaveText(t.densityCompact);
|
|
await scan(page, info, `empty-trash-${suffix}`);
|
|
|
|
// Only this GET is held/failed; loaded data, actions and all mutations above
|
|
// use the real owner API. Retry keeps the existing error contract.
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
await page.route(`${apiUrl}/jobapplications?*`, async route => { await held; await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic list unavailable" }) }); });
|
|
await page.goto("/jobs?q=Density");
|
|
await expect(main.getByText(t.loading, { exact: true })).toBeVisible();
|
|
await chooseDensity(t.densityCompact);
|
|
release();
|
|
await expect(main.getByRole("alert")).toBeVisible();
|
|
await capture(page, info, `list-error-${suffix}`);
|
|
await scan(page, info, `list-error-${suffix}`);
|
|
await page.unroute(`${apiUrl}/jobapplications?*`);
|
|
await main.getByRole("button", { name: t.retry, exact: true }).click();
|
|
await expect(first).toBeVisible();
|
|
await expect(main.getByRole("alert")).toHaveCount(0);
|
|
await expect(density).toHaveText(t.densityCompact);
|
|
});
|
|
}
|