feat(redesign): migrate Jobs Board presentation (P5-04a)
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test, 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(cookie => cookie.name === "XSRF-TOKEN")!.value;
|
||||
return { "X-CSRF-TOKEN": csrf, "X-Session-Context": me.sessionContext as string };
|
||||
}
|
||||
|
||||
async function capture(page: Page, info: TestInfo, name: string) {
|
||||
const path = 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([]);
|
||||
}
|
||||
|
||||
test("[PRES-J07 J05 W01 W02 E07 E08 E09] full Board keeps its own population, moves, focus and route", async ({ page, playwright, context }, info) => {
|
||||
test.setTimeout(240_000);
|
||||
const admin = await playwright.request.newContext();
|
||||
const ids: number[] = [];
|
||||
let headers: Record<string, string>;
|
||||
try {
|
||||
const adminHeaders = await login(admin, "e2e@example.test");
|
||||
const email = `p504a-${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);
|
||||
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;
|
||||
for (let index = 0; index < 27; index++) {
|
||||
const result = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: {
|
||||
companyId,
|
||||
jobTitle: index === 0 ? "Seniorrådgiver for plattformutvikling og digitale fellestjenester" : `Board ${String(index).padStart(2, "0")} Engineer`,
|
||||
status: index === 1 ? "Offer" : index === 2 ? "Take-home assignment" : "Saved",
|
||||
location: "Tromsø / hjemmekontor",
|
||||
dateApplied: "2026-08-01",
|
||||
deadline: "2026-10-15",
|
||||
} });
|
||||
expect(result.status()).toBe(201);
|
||||
ids.push((await result.json()).id);
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/kanban?q=table-only");
|
||||
const cards = page.locator("[data-kanban-card-id]");
|
||||
await expect(cards).toHaveCount(27);
|
||||
expect(new URL(page.url()).search).toBe("?q=table-only");
|
||||
expect((await page.request.get(`${apiUrl}/jobapplications/board`)).status()).toBe(200);
|
||||
const tablePage = await (await page.request.get(`${apiUrl}/jobapplications?page=1&pageSize=15&q=Board`)).json();
|
||||
expect(tablePage.items.length).toBe(15);
|
||||
expect(tablePage.total).toBe(26);
|
||||
|
||||
for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb"] as const) {
|
||||
const t = translations[language];
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).status()).toBe(204);
|
||||
await page.evaluate(({ mode, language }) => { localStorage.setItem("jobtracker.themeMode", mode); localStorage.setItem("uiLanguage", language); }, { mode, language });
|
||||
await page.reload();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode);
|
||||
await expect(cards).toHaveCount(27);
|
||||
await expect(page.getByRole("group", { name: t.kanbanColumnLabel.replace("{label}", t.pipelineGroupNotApplied) })).toBeVisible();
|
||||
await expect(page.getByRole("group", { name: t.kanbanColumnNotDropTarget.replace("{label}", t.kanbanGroupOther) })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: t.jobApplications })).toHaveAttribute("href", "/jobs");
|
||||
await capture(page, info, `board-desktop-${mode}-${language}`);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.getByRole("group", { name: t.kanbanColumnLabel.replace("{label}", t.pipelineGroupNotApplied) })).toBeVisible();
|
||||
await capture(page, info, `board-phone-${mode}-${language}`);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
if (language === "en") await scan(page, info, `board-phone-${mode}`);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
}
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.setViewportSize({ width: 1024, height: 900 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await page.setViewportSize({ width: 768, height: 900 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await page.setViewportSize({ width: 320, height: 720 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await capture(page, info, "board-phone-320-dark-nb");
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
const t = translations.nb;
|
||||
const saved = page.locator(`[data-kanban-card-id="${ids[0]}"]`);
|
||||
const savedControl = saved.locator('[role="button"]');
|
||||
const active = page.getByRole("group", { name: t.kanbanColumnLabel.replace("{label}", t.pipelineGroupActive) });
|
||||
const other = page.getByRole("group", { name: t.kanbanColumnNotDropTarget.replace("{label}", t.kanbanGroupOther) });
|
||||
await savedControl.focus();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(savedControl).toHaveAttribute("aria-pressed", "true");
|
||||
await other.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(other).toHaveAttribute("data-drop-state", "invalid");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(savedControl).toHaveAttribute("aria-pressed", "false");
|
||||
await savedControl.focus(); await page.keyboard.press("Space");
|
||||
await active.focus();
|
||||
await capture(page, info, "board-active-drop-dark-nb");
|
||||
const move = page.waitForResponse(response => response.url() === `${apiUrl}/jobapplications/${ids[0]}/status` && response.request().method() === "PATCH");
|
||||
await page.keyboard.press("Enter");
|
||||
expect((await move).status()).toBe(204);
|
||||
await expect(active.locator(`[data-kanban-card-id="${ids[0]}"] [role="button"]`)).toBeFocused();
|
||||
await capture(page, info, "board-keyboard-move-dark-nb");
|
||||
|
||||
const pointerCard = page.locator(`[data-kanban-card-id="${ids[25]}"]`);
|
||||
const closed = page.getByRole("group", { name: t.kanbanColumnLabel.replace("{label}", t.pipelineGroupClosed) });
|
||||
await expect(pointerCard).toContainText("Board 25 Engineer");
|
||||
await page.evaluate(() => {
|
||||
window.addEventListener("dragstart", event => {
|
||||
(window as typeof window & { __boardDragSource?: string }).__boardDragSource =
|
||||
(event.target as HTMLElement).closest("[data-kanban-card-id]")?.getAttribute("data-kanban-card-id") ?? "";
|
||||
}, { capture: true, once: true });
|
||||
});
|
||||
const pointerMove = page.waitForResponse(response => response.url() === `${apiUrl}/jobapplications/${ids[25]}/status` && response.request().method() === "PATCH", { timeout: 15_000 });
|
||||
await pointerCard.dragTo(closed, { timeout: 15_000 });
|
||||
expect(await page.evaluate(() => (window as typeof window & { __boardDragSource?: string }).__boardDragSource)).toBe(String(ids[25]));
|
||||
expect((await pointerMove).status()).toBe(204);
|
||||
await expect(closed.locator(`[data-kanban-card-id="${ids[25]}"]`)).toBeVisible();
|
||||
await expect(closed.locator(`[data-kanban-card-id="${ids[25]}"] [role="button"]`)).toBeFocused();
|
||||
await capture(page, info, "board-pointer-move-dark-nb");
|
||||
|
||||
const customCard = page.locator(`[data-kanban-card-id="${ids[2]}"]`);
|
||||
await expect(customCard.getByText("Take-home assignment", { exact: true })).toBeVisible();
|
||||
await customCard.getByRole("button", { name: `${t.jobTableQuickStatus}: Board 02 Engineer` }).click();
|
||||
const preciseMove = page.waitForResponse(response => response.url() === `${apiUrl}/jobapplications/${ids[2]}/status` && response.request().method() === "PATCH");
|
||||
await page.getByRole("menuitem", { name: t.jobTableSetStatus.replace("{status}", t.statusInterview) }).click();
|
||||
expect((await preciseMove).status()).toBe(204);
|
||||
await expect(active.locator(`[data-kanban-card-id="${ids[2]}"] [role="button"]`)).toBeFocused();
|
||||
await capture(page, info, "board-custom-status-dark-nb");
|
||||
|
||||
await page.route(`**/api/jobapplications/${ids[4]}/status`, route => route.fulfill({ status: 503, json: { message: "Synthetic move failure" } }));
|
||||
const failed = page.locator(`[data-kanban-card-id="${ids[4]}"]`);
|
||||
await failed.getByRole("button", { name: `${t.jobTableQuickStatus}: Board 04 Engineer` }).click();
|
||||
await page.getByRole("menuitem", { name: t.jobTableSetStatus.replace("{status}", t.statusInterview) }).click();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Synthetic move failure" })).toBeVisible();
|
||||
await expect(page.getByRole("group", { name: t.kanbanColumnLabel.replace("{label}", t.pipelineGroupNotApplied) }).locator(`[data-kanban-card-id="${ids[4]}"]`)).toBeVisible();
|
||||
await capture(page, info, "board-failed-move-dark-nb");
|
||||
await page.unroute(`**/api/jobapplications/${ids[4]}/status`);
|
||||
|
||||
await savedControl.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(new RegExp(`/jobs/${ids[0]}$`));
|
||||
await expect(page.getByRole("heading", { name: /Seniorrådgiver/ })).toBeVisible();
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(url => url.pathname === "/kanban" && url.search === "?q=table-only");
|
||||
await expect(savedControl).toBeFocused();
|
||||
} finally {
|
||||
await admin.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("[PRES-J07 E08] Board loading, retryable error and fully empty columns", async ({ page }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Email").fill("e2e@example.test");
|
||||
await page.getByLabel("Current password").fill(password);
|
||||
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
let release!: () => void;
|
||||
let state: "loading" | "error" | "empty" = "loading";
|
||||
await page.route("**/api/jobapplications/board", async route => {
|
||||
if (state === "loading") await new Promise<void>(resolve => { release = resolve; });
|
||||
if (state === "error") await route.fulfill({ status: 503, json: { message: "Synthetic board outage" } });
|
||||
else await route.fulfill({ json: [] });
|
||||
});
|
||||
await page.goto("/kanban", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("progressbar")).toBeVisible();
|
||||
await capture(page, info, "board-loading-light-en");
|
||||
state = "error"; release();
|
||||
await expect(page.getByText("Unable to load the kanban board")).toBeVisible();
|
||||
await capture(page, info, "board-error-light-en");
|
||||
state = "empty";
|
||||
await page.getByRole("button", { name: "Retry" }).click();
|
||||
await expect(page.getByText("No jobs in this group yet.")).toHaveCount(3);
|
||||
await capture(page, info, "board-empty-light-en");
|
||||
await scan(page, info, "board-empty-light-en");
|
||||
});
|
||||
@@ -47,14 +47,15 @@ test("kanban columns use dark theme surfaces", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/kanban");
|
||||
await expect(page.getByText("Synthetic platform engineer")).toBeVisible();
|
||||
const column = page.getByText("Not Applied", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiPaper-root')][1]");
|
||||
expect(await column.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe("rgb(245, 242, 250)");
|
||||
const column = page.getByRole("group", { name: "Not Applied column" });
|
||||
const darkSurface = await column.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
if (updateAuditEvidence) await page.screenshot({ path: path.resolve("..", "docs", "audits", "evidence", "ux-003-kanban-dark-after.png"), fullPage: true });
|
||||
|
||||
const card = page.getByText("Synthetic platform engineer", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiCard-root')][1]");
|
||||
await card.focus();
|
||||
const cardControl = card.locator('[role="button"]');
|
||||
await cardControl.focus();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(card).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(cardControl).toHaveAttribute("aria-pressed", "true");
|
||||
const active = page.getByRole("group", { name: "Active column" });
|
||||
await expect(active).toHaveAttribute("data-drop-state", "valid");
|
||||
await expect(page.getByRole("group", { name: "Other column, not a drop target" })).toHaveAttribute("aria-disabled", "true");
|
||||
@@ -86,11 +87,11 @@ test("kanban columns use dark theme surfaces", async ({ page }) => {
|
||||
await page.reload();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "light");
|
||||
const lightColumn = page.getByText("Not Applied", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiPaper-root')][1]");
|
||||
expect(await lightColumn.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe("rgb(245, 242, 250)");
|
||||
expect(await lightColumn.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe(darkSurface);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const lightCard = page.getByText("Synthetic platform engineer", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiCard-root')][1]");
|
||||
await lightCard.hover();
|
||||
expect(await lightCard.evaluate((element) => getComputedStyle(element).transform)).not.toBe("none");
|
||||
await expect(lightCard.getByRole("button", { name: "Quick status: Synthetic platform engineer" })).toBeVisible();
|
||||
await lightCard.dragTo(page.getByRole("group", { name: "Active column" }));
|
||||
await expect.poll(() => moveRequests).toBe(2);
|
||||
|
||||
|
||||
@@ -189,7 +189,8 @@ for (const mode of ["light", "dark"] as const) {
|
||||
await keyboardFocus(page, dialog.getByRole("textbox"));
|
||||
await page.keyboard.press("Tab");
|
||||
const command = dialog.getByRole("button").first();
|
||||
await keyboardFocus(page, command);
|
||||
await expect(command).toBeFocused();
|
||||
await expect.poll(() => command.evaluate(node => getComputedStyle(node).outlineWidth)).toBe("3px");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user