Files
jobtrackingapp/job-tracker-ui/e2e/settings-page-layout.spec.ts
T

195 lines
12 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<{ headers: Record<string, string> }>({
headers: async ({ playwright, page, context }, provide) => {
const admin = await playwright.request.newContext();
try {
const adminHeaders = await login(admin, "e2e@example.test");
const email = `p404-${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();
});
await provide(headers);
} finally { await admin.dispose(); }
},
});
async function capture(page: Page, info: TestInfo, name: string) {
const path = process.env.P4_04_CAPTURE === "true" ? `../docs/redesign/phase-4/p4-04/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 }, null, 2), contentType: "application/json" });
expect(result.violations).toEqual([]);
}
async function reflow(page: Page, info: TestInfo, name: string) {
// Test actual descendants, not body.scrollWidth: the inherited body overflow rule
// must not disguise a child/action extending beyond the available reading area.
const outside = await page.locator("main").evaluate(main => {
const bounds = main.getBoundingClientRect();
return Array.from(main.querySelectorAll("h1,h2,h3,p,button,[role='combobox'],.MuiFormControlLabel-root,a"))
.filter(node => node.getClientRects().length && getComputedStyle(node).visibility !== "hidden")
.map(node => ({ text: node.textContent?.slice(0, 90), left: node.getBoundingClientRect().left, right: node.getBoundingClientRect().right }))
.filter(rect => rect.left < bounds.left - 1 || rect.right > Math.min(bounds.right, innerWidth) + 1);
});
await info.attach(`${name}-reflow`, { body: JSON.stringify(outside), contentType: "application/json" });
expect(outside).toEqual([]);
}
for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb"] as const) {
test(`[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] ${mode}/${language} Settings headings, reflow and retained actions`, async ({ page, headers }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const suffix = `${mode}-${language}`;
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, data: { language: language === "nb" ? "nb-NO" : "en" } })).ok()).toBe(true);
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(language === "nb" ? "/settings/?source=preservation" : "/settings");
const main = page.getByRole("main");
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode);
await expect(main.getByRole("heading", { level: 1 })).toHaveText(t.settings);
await expect(main.getByRole("switch", { name: t.settingsAiEnabled, exact: true })).toBeVisible();
await expect(main.getByText(t.settingsUsageNoAi, { exact: true })).toBeVisible();
await expect(main.getByRole("switch", { name: t.settingsAiExternalAllowed, exact: true })).toBeDisabled();
for (const title of [t.settingsAppearance, t.settingsLanguageTitle, t.settingsJobs, t.settingsAiPrivacyTitle, t.settingsUsageTitle, t.settingsQuickCaptureTitle, t.connectedAccounts]) {
await expect(main.getByRole("heading", { level: 2, name: title, exact: true })).toHaveCount(1);
}
await scan(page, info, `general-${suffix}`);
for (const width of [1920, 1440, 1024, 768, 767, 390, 320]) {
await page.setViewportSize({ width, height: 900 });
await reflow(page, info, `general-${width}-${suffix}`);
if ([1440, 390, 320].includes(width)) {
await page.evaluate(() => scrollTo(0, 0));
await capture(page, info, `general-${width}-${suffix}`);
}
}
await scan(page, info, `phone-${suffix}`);
// Synthetic long localized title, with no CSS injection or hidden controls.
await main.getByRole("heading", { level: 1 }).evaluate((node, title) => { node.textContent = title; }, language === "nb"
? "Innstillinger for jobbpreferanser, oppfølginger og tilkoblede innbokser"
: "Settings for job preferences, follow-up reminders and connected inboxes");
await reflow(page, info, `long-heading-${suffix}`);
await capture(page, info, `long-heading-${suffix}`);
const chooseSection = async (name: string) => {
if (page.viewportSize()!.width < 768) {
const selector = main.getByRole("combobox", { name: t.settingsNavigation, exact: true });
await selector.focus();
await selector.press("Enter");
await page.getByRole("option", { name, exact: true }).focus();
await page.keyboard.press("Enter");
await expect(selector).toBeFocused();
} else await main.getByRole("tab", { name, exact: true }).click();
};
// Initial rules failure retains heading/error; existing section re-entry reloads.
await page.route(`${apiUrl}/rules`, route => route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic rules unavailable" }) }));
await chooseSection(t.settingsTabFollowUps);
await expect(main.getByRole("alert")).toContainText("Synthetic rules unavailable");
await expect(main.getByRole("heading", { level: 2, name: t.rulesTitle })).toBeVisible();
await capture(page, info, `rules-error-${suffix}`);
await scan(page, info, `rules-error-${suffix}`);
await page.unroute(`${apiUrl}/rules`);
await chooseSection(t.settingsTabGeneral);
await chooseSection(t.settingsTabFollowUps);
const rule = main.getByRole("spinbutton", { name: t.rulesAppliedFollowUpDays, exact: true });
await rule.fill("5");
for (const width of [320, 390, 768, 1024, 1440]) {
await page.setViewportSize({ width, height: 900 });
await expect(rule).toHaveValue("5");
await reflow(page, info, `rules-${width}-${suffix}`);
}
const response = page.waitForResponse(r => r.url() === `${apiUrl}/rules` && r.request().method() === "PUT");
await main.getByRole("button", { name: t.rulesSave, exact: true }).click();
expect((await response).status()).toBe(204);
expect((await (await page.request.get(`${apiUrl}/rules`)).json()).appliedFollowUpDays).toBe(5);
// Hold only the local notification read to exercise loading in the same layout.
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(`${apiUrl}/notification-settings`, async route => { await held; await route.continue(); });
await page.reload();
await page.setViewportSize({ width: 320, height: 900 });
await chooseSection(t.settingsTabNotifications);
await expect(main.locator(".MuiSkeleton-root")).toBeVisible();
await capture(page, info, `notifications-loading-${suffix}`);
release();
const pref = main.getByRole("checkbox", { name: t.settingsNotificationsFollowUpReminders, exact: true });
await expect(pref).toBeChecked();
await page.unroute(`${apiUrl}/notification-settings`);
await pref.uncheck();
let fail = true;
await page.route(`${apiUrl}/notification-settings`, route => {
if (route.request().method() === "PUT" && fail) { fail = false; return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic notification save failure" }) }); }
return route.continue();
});
const save = main.getByRole("button", { name: t.settingsNotificationsSave, exact: true });
await save.focus();
const savedNotification = page.waitForResponse(r => r.url() === `${apiUrl}/notification-settings` && r.request().method() === "PUT");
await save.press("Enter");
expect((await savedNotification).status()).toBe(503);
await expect(main.getByRole("alert")).toContainText("Synthetic notification save failure");
await expect(pref).not.toBeChecked();
await reflow(page, info, `notifications-error-${suffix}`);
await capture(page, info, `notifications-error-${suffix}`);
await scan(page, info, `notifications-error-${suffix}`);
const retriedNotification = page.waitForResponse(r => r.url() === `${apiUrl}/notification-settings` && r.request().method() === "PUT");
await save.press("Enter");
expect((await retriedNotification).status()).toBe(200);
await expect(main.getByRole("alert")).toHaveCount(0);
expect((await (await page.request.get(`${apiUrl}/notification-settings`)).json()).emailFollowUpRemindersEnabled).toBe(false);
await page.reload();
await chooseSection(t.settingsTabNotifications);
await expect(pref).not.toBeChecked();
await chooseSection(t.settingsTabBackup);
await expect(main.getByText(t.accountDeleteDisabled, { exact: true })).toBeVisible();
await expect(main.getByRole("button", { name: t.accountDeleteButton, exact: true })).toHaveCount(0);
await reflow(page, info, `backup-${suffix}`);
await capture(page, info, `backup-${suffix}`);
await scan(page, info, `backup-${suffix}`);
const download = page.waitForEvent("download");
await main.getByRole("button", { name: t.accountExportDownload, exact: true }).click();
expect((await download).suggestedFilename()).toMatch(/\.zip$/);
// The existing fixed toast can temporarily cover bottom-of-page actions.
// Let it dismiss normally before certifying/capturing the focused action.
await expect(page.getByRole("alert").filter({ hasText: t.accountExportDownloaded })).toHaveCount(0, { timeout: 15_000 });
await chooseSection(t.settingsTabGeneral);
const manage = main.getByRole("button", { name: t.settingsManageConnectedAccounts, exact: true });
await manage.focus();
await page.keyboard.press("Shift+Tab");
await page.keyboard.press("Tab");
await expect(manage).toBeFocused();
expect(await manage.evaluate(node => getComputedStyle(node).outlineStyle)).toBe("solid");
await manage.scrollIntoViewIfNeeded();
await expect.poll(() => manage.evaluate(node => {
const rect = node.getBoundingClientRect();
const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
return node.contains(hit) ? "visible" : `${hit?.tagName}: ${hit?.getAttribute("class")} at ${rect.x},${rect.y},${rect.width},${rect.height}`;
})).toBe("visible");
await capture(page, info, `connected-action-${suffix}`);
await manage.press("Enter");
await expect(page).toHaveURL(/\/settings\/connected-accounts$/);
expect((await page.request.get(`${apiUrl}/users`)).status()).toBe(403);
});
}