195 lines
12 KiB
TypeScript
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);
|
|
});
|
|
}
|