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] Board query membership matches Table and preserves 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; 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 }); // Native API membership, not just visible counts. Board uses one complete read; only this // regression test enumerates Table pages to compare the full logical population. for (const query of ['', 'q=Board', 'status=Saved', 'status=Take-home%20assignment', 'q=Board&status=Saved', 'q=absent', `companyId=${companyId}&location=Troms%C3%B8&sortBy=jobTitle&sortDir=asc`]) { const boardResponse = await page.request.get(`${apiUrl}/jobapplications/board?${query}`); expect(boardResponse.status()).toBe(200); const board = await boardResponse.json(); const tableIds: number[] = []; let total = 0; for (let number = 1; ; number++) { const response = await page.request.get(`${apiUrl}/jobapplications?${query}&page=${number}&pageSize=15`); expect(response.status()).toBe(200); const data = await response.json(); total = data.total; tableIds.push(...data.items.map((item: { id: number }) => item.id)); if (tableIds.length >= total) break; } expect(board.map((item: { id: number }) => item.id).sort((a: number, b: number) => a - b)).toEqual([...tableIds].sort((a, b) => a - b)); expect(board.length).toBe(total); expect(tableIds.every(id => ids.includes(id))).toBe(true); } await page.goto("/kanban?q=Board&status=Saved"); await expect(page.locator('[data-kanban-card-id]')).toHaveCount(24); await capture(page, info, "board-filtered-query-light-en"); await page.getByRole('link', { name: 'Table', exact: true }).click(); await expect(page).toHaveURL(url => url.pathname === '/jobs' && url.search === '?q=Board&status=Saved&view=table'); await expect(page.locator('[data-job-row-id]')).toHaveCount(15); const visibleIds = await page.locator('[data-job-row-id]').evaluateAll(rows => rows.map(row => Number(row.getAttribute('data-job-row-id')))); expect(visibleIds.every(id => ids.slice(3).includes(id))).toBe(true); await page.goBack(); await expect(page.locator('[data-kanban-card-id]')).toHaveCount(24); await page.goForward(); await expect(page.locator('[data-job-row-id]')).toHaveCount(15); await page.getByRole('button', { name: 'Go to next page' }).click(); await expect(page.locator('[data-job-row-id]')).toHaveCount(9); const secondPageIds = await page.locator('[data-job-row-id]').evaluateAll(rows => rows.map(row => row.getAttribute('data-job-row-id'))); const viewReads: string[] = []; const recordRead = (request: import('@playwright/test').Request) => { if (request.method() === 'GET' && /^\/api\/jobapplications(?:\/board)?$/.test(new URL(request.url()).pathname)) viewReads.push(new URL(request.url()).pathname); }; page.on('request', recordRead); const boardSwitch = page.getByRole('link', { name: 'Board', exact: true }); await boardSwitch.focus(); await page.keyboard.press('Enter'); await expect(page.locator('[data-kanban-card-id]')).toHaveCount(24); await expect(boardSwitch).toBeFocused(); await expect(boardSwitch).toHaveAttribute('aria-current', 'page'); expect(viewReads).toEqual(['/api/jobapplications/board']); expect((await page.locator('[data-kanban-card-id]').evaluateAll(cards => cards.map(card => Number(card.getAttribute('data-kanban-card-id')))))).toEqual(expect.arrayContaining(ids.slice(3))); await capture(page, info, 'integrated-filtered-board-keyboard'); await page.getByRole('link', { name: 'Table', exact: true }).click(); await expect(page.locator('[data-job-row-id]')).toHaveCount(9); expect(await page.locator('[data-job-row-id]').evaluateAll(rows => rows.map(row => row.getAttribute('data-job-row-id')))).toEqual(secondPageIds); expect(viewReads).toEqual(['/api/jobapplications/board', '/api/jobapplications']); page.off('request', recordRead); await capture(page, info, 'integrated-filtered-table-page-two'); await page.goBack(); await expect(page.locator('[data-kanban-card-id]')).toHaveCount(24); const filteredControl = page.locator(`[data-kanban-card-id="${ids[4]}"] [role="button"]`); await filteredControl.focus(); await page.keyboard.press('Space'); await page.getByRole('group', { name: 'Active column' }).focus(); await page.keyboard.press('Enter'); await expect(page.locator('[data-kanban-card-id]')).toHaveCount(23); await expect(page.locator(`[data-kanban-card-id="${ids[4]}"]`)).toHaveCount(0); await expect(page.getByRole('group', { name: 'Not Applied column' })).toBeFocused(); expect((await page.request.patch(`${apiUrl}/jobapplications/${ids[4]}/status`, { headers, data: { status: 'Saved' } })).status()).toBe(204); await page.goto('/kanban?q=absent'); await expect(page.locator('[data-kanban-card-id]')).toHaveCount(0); await expect(page.getByText('No jobs in this group yet.')).toHaveCount(3); await capture(page, info, 'board-zero-query-light-en'); await page.getByRole('link', { name: 'Table', exact: true }).click(); await expect(page.getByText('No jobs found.')).toBeVisible(); await expect(page.locator('[data-job-row-id]')).toHaveCount(0); await page.getByRole('link', { name: 'Board', exact: true }).click(); await expect(page.getByText('No jobs in this group yet.')).toHaveCount(3); await page.goto('/jobs?q=Board&status=Take-home%20assignment&view=table'); await expect(page.locator('[data-job-row-id]')).toHaveCount(1); await expect(page.locator(`[data-job-row-id="${ids[2]}"]`)).toBeVisible(); await page.getByRole('link', { name: 'Board', exact: true }).click(); await expect(page.locator('[data-kanban-card-id]')).toHaveCount(1); await expect(page.locator(`[data-kanban-card-id="${ids[2]}"]`)).toBeVisible(); await capture(page, info, 'integrated-custom-status-board'); await page.goto("/kanban?source=board"); const cards = page.locator("[data-kanban-card-id]"); await expect(cards).toHaveCount(27); expect(new URL(page.url()).search).toBe("?source=board&view=board"); 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.jobsTableView, exact: true })).toHaveAttribute("href", "/jobs?source=board&view=table"); await capture(page, info, `board-desktop-${mode}-${language}`); await page.getByRole("link", { name: t.jobsTableView, exact: true }).click(); await expect(page.locator('[data-job-row-id]')).toHaveCount(15); await capture(page, info, `integrated-table-desktop-${mode}-${language}`); await page.getByRole("link", { name: t.jobsBoardView, exact: true }).click(); await expect(cards).toHaveCount(27); 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}`); await page.getByRole("link", { name: t.jobsTableView, exact: true }).click(); await expect(page.locator('[data-job-row-id]')).toHaveCount(15); await capture(page, info, `integrated-table-phone-${mode}-${language}`); await page.getByRole("link", { name: t.jobsBoardView, exact: true }).click(); await expect(cards).toHaveCount(27); 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 === "/jobs" && url.search === "?source=board&view=board"); await expect(savedControl).toBeFocused(); } finally { await admin.dispose(); } }); test("[PRES-J05 J07 E07 W01] legacy Board replaces history and preserves query, hash and route state; default Jobs stays Table", async ({ page }) => { await login(page.request, "e2e@example.test"); await page.goto('/jobs?view=unknown&q=absent'); await expect(page.getByRole('link', { name: 'Table', exact: true })).toHaveAttribute('aria-current', 'page'); await expect(page.getByText('No jobs found.')).toBeVisible(); await page.evaluate(() => history.pushState({ ...history.state, usr: { integrationSentinel: 'preserved' } }, '', '/kanban?q=absent&status=Custom+review&page=2&view=table#retained')); const length = await page.evaluate(() => history.length); await page.reload(); await expect(page).toHaveURL(url => url.pathname === '/jobs' && url.searchParams.get('view') === 'board' && url.searchParams.get('status') === 'Custom review' && url.searchParams.get('q') === 'absent' && url.searchParams.get('page') === '2' && url.hash === '#retained'); await expect(page.getByRole('link', { name: 'Board', exact: true })).toHaveAttribute('aria-current', 'page'); expect(await page.evaluate(() => history.length)).toBe(length); expect(await page.evaluate(() => history.state.usr.integrationSentinel)).toBe('preserved'); await page.goBack(); await expect(page).toHaveURL(/\/jobs\?view=unknown&q=absent$/); await expect(page.getByRole('link', { name: 'Table', exact: true })).toHaveAttribute('aria-current', 'page'); }); 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(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" }); // The shared workspace also has a lazy-renderer loading state. Wait for the actual read. await expect.poll(() => typeof release).toBe("function"); 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"); });