Files
jobtrackingapp/job-tracker-ui/e2e/shell-readiness.spec.ts
T
2026-09-29 23:01:05 +02:00

456 lines
27 KiB
TypeScript

import AxeBuilder from "@axe-core/playwright";
import { expect, test as base, type APIRequestContext, type APIResponse, 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>; jobId: number; companyId: number; cvId: number } }>({
owner: async ({ playwright, page, context }, provide) => {
const admin = await playwright.request.newContext();
try {
const adminHeaders = await login(admin, "e2e@example.test");
const email = `p500-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: ["Admin"] } })).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 beredskap æøå" } });
expect(company.status()).toBe(201);
const companyId = (await company.json()).id;
const job = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: "Tilgjengelig plattformutvikler", status: "Saved" } });
expect(job.ok()).toBe(true);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { version: "1", contact: { fullName: "Åse Eksempel" }, skills: ["Norsk", "C#"] } } })).status()).toBe(200);
const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "Readiness CV" } });
expect(cv.status()).toBe(200);
await provide({ headers, jobId: (await job.json()).id, companyId, cvId: (await cv.json()).id });
} finally { await admin.dispose(); }
},
});
async function settled(page: Page) {
await page.evaluate(async () => {
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await Promise.all(document.getAnimations().filter(a => a.playState === "running" && Number.isFinite(a.effect?.getComputedTiming().endTime)).map(a => a.finished.catch(() => undefined)));
});
}
async function capture(page: Page, info: TestInfo, state: string) {
await settled(page);
const path = info.outputPath(`${state}.png`);
await page.screenshot({ path });
await info.attach(state, { path, contentType: "image/png" });
}
async function scan(page: Page, info: TestInfo, state: string, selector?: string) {
await settled(page);
let axe = new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]);
if (selector) axe = axe.include(selector);
const result = await axe.analyze();
await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: "application/json" });
expect(result.violations).toEqual([]);
}
async function exposed(target: Locator) {
await expect.poll(() => target.evaluate(node => {
const r = node.getBoundingClientRect();
return r.left >= 0 && r.right <= innerWidth + 1 && r.top >= 0 && r.bottom <= innerHeight + 1 && node.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2));
})).toBe(true);
}
async function ring(target: Locator) {
await expect.poll(() => target.evaluate(node => ({ width: getComputedStyle(node).outlineWidth, style: getComputedStyle(node).outlineStyle }))).toEqual({ width: "3px", style: "solid" });
}
for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb"] as const) {
const suffix = `${mode}-${language}`;
test.describe(suffix, () => {
test.beforeEach(async ({ page, owner }) => {
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: owner.headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).ok()).toBe(true);
});
test(`[P5-00 PRES-E07 E08 A03] ${suffix} mobile labels, focus and admin reflow`, async ({ page }, info) => {
test.setTimeout(120_000);
const t = translations[language];
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/settings");
await expect(page.getByRole("heading", { level: 1, name: t.settings })).toBeVisible();
await page.getByRole("button", { name: t.collapseSidebar }).click();
await page.reload();
await expect(page.getByRole("button", { name: t.expandSidebar })).toBeVisible();
for (const width of [1440, 1024, 1023, 900, 768, 767, 390, 320]) {
await page.setViewportSize({ width, height: width < 360 ? 740 : width < 768 ? 844 : 900 });
await settled(page);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
const header = page.getByRole("banner");
await exposed(page.getByRole("heading", { level: 1, name: t.settings }));
const controls = header.locator("button");
for (const control of await controls.all()) if (await control.isVisible()) await exposed(control);
if (width >= 1024) {
await expect(header.getByRole("button", { name: t.expandSidebar, exact: true })).toHaveCount(1);
await expect(header.getByRole("button", { name: t.openNavigation, exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: t.dashboard, exact: true })).toHaveCount(1);
await exposed(page.getByRole("button", { name: t.dashboard, exact: true }));
await exposed(page.getByTestId("admin-version-badge"));
} else {
await expect(header.getByRole("button", { name: t.openNavigation, exact: true })).toHaveCount(1);
await expect(header.getByRole("button", { name: t.expandSidebar, exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: t.dashboard, exact: true })).toHaveCount(0);
await expect(page.getByTestId("admin-version-badge")).toHaveCount(0);
const user = page.getByRole("button", { name: t.user, exact: true });
await user.click();
await expect(page.getByRole("menu")).toContainText(t.applicationVersion.replace("{version}", "e2e-verification"));
await expect(page.getByRole("menu")).toContainText("e2e1234");
if (width === 320) {
await expect(page.getByRole("menuitem", { name: t.profileMenu, exact: true })).toBeFocused();
await capture(page, info, `account-metadata-${suffix}`);
await scan(page, info, `account-metadata-${suffix}`, '[role="menu"]');
}
await page.keyboard.press("Escape");
await expect(user).toBeFocused();
if ([768, 900, 1023].includes(width)) {
const navigation = header.getByRole("button", { name: t.openNavigation, exact: true });
await navigation.focus();
await navigation.press("Enter");
const first = page.getByRole("dialog", { name: t.navigation }).getByRole("button", { name: t.dashboard, exact: true });
await expect(first).toBeFocused();
await exposed(first);
await page.keyboard.press("Escape");
await expect(navigation).toBeFocused();
}
}
if ([1440, 900, 768, 320].includes(width)) await capture(page, info, `admin-${width}-${suffix}`);
}
const trigger = page.getByRole("button", { name: t.openNavigation });
await trigger.focus();
await trigger.press("Enter");
const drawer = page.getByRole("dialog");
await expect(drawer).toHaveAccessibleName(/.+/);
const dashboard = drawer.getByRole("button", { name: t.dashboard, exact: true });
await expect(dashboard.getByText(t.dashboard, { exact: true })).toBeVisible();
// Opening the modal sets a meaningful entry; Tab traverses its controls.
await expect(dashboard).toBeFocused();
await exposed(dashboard);
await ring(dashboard);
await expect(drawer.getByRole("button", { name: t.settings, exact: true })).toHaveAttribute("aria-current", "page");
const links = drawer.getByRole("button");
for (let i = 0; i < await links.count(); i++) {
await page.keyboard.press("Tab");
expect(await drawer.evaluate(node => node.contains(document.activeElement))).toBe(true);
}
await links.last().focus();
await links.last().scrollIntoViewIfNeeded();
await exposed(links.last());
await dashboard.focus();
await dashboard.scrollIntoViewIfNeeded();
await capture(page, info, `drawer-${suffix}`);
await scan(page, info, `drawer-${suffix}`, '[role="dialog"]');
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
await exposed(trigger);
await trigger.press("Enter");
await dashboard.press("Enter");
await expect(page).toHaveURL(/\/dashboard$/);
await expect(drawer).not.toBeVisible();
await page.setViewportSize({ width: 1440, height: 900 });
await expect(page.getByRole("button", { name: t.expandSidebar })).toBeVisible();
await page.getByRole("button", { name: t.expandSidebar }).click();
await page.setViewportSize({ width: 390, height: 844 });
await trigger.click();
await expect(dashboard).toBeFocused();
await exposed(dashboard);
// A hidden temporary drawer must not keep desktop content aria-hidden or trap focus.
await page.setViewportSize({ width: 1024, height: 900 });
await expect(drawer).toHaveCount(0);
const desktopToggle = page.getByRole("button", { name: t.collapseSidebar, exact: true });
await desktopToggle.press("Tab");
await page.keyboard.press("Shift+Tab");
await expect(desktopToggle).toBeFocused();
await ring(desktopToggle);
await expect(page.getByRole("button", { name: t.dashboard, exact: true })).toHaveCount(1);
await page.setViewportSize({ width: 390, height: 844 });
await expect(drawer).toHaveCount(0);
await trigger.press("Enter");
await expect(dashboard).toBeFocused();
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
});
test(`[P5-00 PRES-E07 J03 J08] ${suffix} command list, keyboard and no-write actions`, async ({ page, owner }, info) => {
test.setTimeout(90_000);
const t = translations[language];
await page.setViewportSize({ width: language === "nb" ? 390 : 1440, height: 900 });
await page.goto("/settings");
await expect(page.getByRole("heading", { level: 1, name: t.settings })).toBeVisible();
const trigger = page.getByRole("button", { name: new RegExp(t.quickSearch) });
await trigger.focus();
await trigger.press("Enter");
const dialog = page.getByRole("dialog", { name: t.quickSearch });
const input = dialog.getByRole("textbox", { name: t.quickSearch });
await expect(input).toBeFocused();
await capture(page, info, `commands-${suffix}`);
await scan(page, info, `commands-${suffix}`, '[role="dialog"]');
await page.keyboard.press("Tab");
await expect(dialog.getByRole("button", { name: new RegExp(t.goToDashboard) })).toBeFocused();
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
await page.keyboard.press("Control+k");
await input.fill("Nordlys");
const job = dialog.getByRole("button", { name: /Nordlys.*Tilgjengelig/ });
await expect(job).toBeVisible();
await job.focus();
await job.press("Enter");
await expect(page).toHaveURL(new RegExp(`/jobs/${owner.jobId}$`));
await page.keyboard.press("Control+k");
await input.fill("Nordlys");
const company = dialog.getByRole("button", { name: new RegExp(`^Nordlys beredskap æøå ${t.openCompanies}$`) });
await expect(company).toBeVisible();
await company.press("Enter");
await expect(page).toHaveURL(new RegExp(`/companies\\?edit=${owner.companyId}$`));
// Wait for the route's asynchronous record read before dismissing its modal.
await expect(dialog).not.toBeVisible();
await expect(page.getByRole("dialog", { name: t.companiesEdit })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).not.toBeVisible();
await page.keyboard.press("Control+k");
await input.fill("no-such-readiness-command");
await expect(dialog.getByText(t.noMatchingCommands)).toBeVisible();
await scan(page, info, `commands-empty-${suffix}`, '[role="dialog"]');
await input.fill("");
await dialog.getByRole("button", { name: new RegExp(t.addNewJob) }).press("Enter");
await expect(dialog).not.toBeVisible();
await expect(page.getByRole("dialog")).toBeVisible();
expect((await (await page.request.get(`${apiUrl}/jobapplications`)).json()).total).toBe(1);
});
test(`[P5-02 PRES-E07 E08 E09 S02] ${suffix} utility search and notification states`, async ({ page }, info) => {
test.setTimeout(90_000);
const t = translations[language];
await page.setViewportSize({ width: language === "nb" ? 390 : 1440, height: language === "nb" ? 844 : 900 });
await page.goto("/settings");
await expect(page.getByRole("heading", { level: 1, name: t.settings })).toBeVisible();
await page.route("**/api/jobapplications?**", async route => {
if (new URL(route.request().url()).searchParams.get("q") === "outage") {
await route.fulfill({ status: 503, contentType: "application/json", body: '{"message":"Search is temporarily unavailable"}' });
} else await route.continue();
});
const trigger = page.getByRole("button", { name: new RegExp(t.quickSearch) });
await trigger.focus();
await trigger.press("Enter");
const dialog = page.getByRole("dialog", { name: t.quickSearch });
const input = dialog.getByRole("textbox", { name: t.quickSearch });
await input.fill("outage");
await expect(dialog.getByRole("alert")).toBeVisible();
await expect(dialog.getByText(t.noMatchingCommands)).toHaveCount(0);
await capture(page, info, `utility-error-${suffix}`);
await scan(page, info, `utility-error-${suffix}`, '[role="dialog"]');
await input.fill("");
await input.press("End");
await input.press("Home");
await input.press("ArrowDown");
await input.press("Enter");
await expect(page).toHaveURL(/\/jobs$/);
const notification = {
id: "22222222-2222-2222-2222-222222222222", operationId: null, kind: "operation_failed",
title: "CV export failed", message: "Review the operation and try again.", linkPath: "/operations",
createdAtUtc: "2026-08-15T12:00:00Z", readAtUtc: null,
};
await page.route("**/api/notifications?limit=10", route => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([notification]) }));
await page.route("**/api/notifications/*/read", route => route.fulfill({ status: 200, body: "{}" }));
await page.route("**/api/notifications/*", route => route.request().method() === "DELETE"
? route.fulfill({ status: 204, body: "" }) : route.continue());
await page.getByRole("button", { name: t.notifications, exact: true }).click();
await expect(page.getByText("CV export failed")).toBeVisible();
await expect(page.getByText(t.notificationsUnread)).toBeVisible();
await capture(page, info, `utility-notifications-${suffix}`);
await scan(page, info, `utility-notifications-${suffix}`, ".MuiPopover-paper");
await page.getByRole("button", { name: t.notificationsMarkRead }).click();
await expect(page.getByRole("button", { name: t.notificationsMarkRead })).toHaveCount(0);
await page.getByRole("button", { name: t.notificationsDismiss }).click();
await expect(page.getByText("CV export failed")).toHaveCount(0);
});
test(`[P5-00 PRES-E08 C06 S05] ${suffix} real Tooltip contrast and open-toast action access`, async ({ page, owner }, info) => {
test.setTimeout(120_000);
const t = translations[language];
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(`/career/builder/${owner.cvId}`);
await page.getByRole("tab", { name: t.cvEditorContent, exact: true }).click();
const headline = page.getByRole("textbox", { name: t.cvEditorHeadlineOverride, exact: true });
await headline.fill("Edited readiness headline");
await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible();
await page.getByRole("button", { name: t.cvEditorUndo, exact: true }).click();
const redo = page.getByRole("button", { name: t.cvEditorRedo, exact: true });
await expect(redo).toBeEnabled();
await expect(redo).toHaveAccessibleName(t.cvEditorRedo);
await redo.hover();
const tooltip = page.getByRole("tooltip", { name: t.cvEditorRedo, exact: true });
await expect(tooltip).toBeVisible();
await capture(page, info, `redo-tooltip-${suffix}`);
await scan(page, info, `redo-tooltip-${suffix}`, '[role="tooltip"]');
await page.mouse.move(0, 0);
await redo.focus();
await page.keyboard.press("Shift+Tab");
await page.keyboard.press("Tab");
await expect(redo).toBeFocused();
await expect(tooltip).toBeVisible();
await ring(redo);
await page.goto("/settings");
await page.getByRole("button", { name: t.notifications, exact: true }).click();
const notificationClose = page.getByRole("button", { name: t.close, exact: true });
await expect(notificationClose).toHaveAccessibleName(t.close);
await notificationClose.hover();
await expect(page.getByRole("tooltip", { name: t.close, exact: true })).toBeVisible();
await scan(page, info, `shell-tooltip-${suffix}`, '[role="tooltip"]');
await notificationClose.click();
const main = page.getByRole("main");
let exported: APIResponse | undefined;
for (const width of [1440, 390, 320]) {
await page.setViewportSize({ width, height: width < 360 ? 740 : width < 768 ? 844 : 900 });
const choose = async (label: string) => {
if (width >= 768) await main.getByRole("tab", { name: label, exact: true }).click();
else {
await main.getByRole("combobox", { name: t.settingsNavigation, exact: true }).click();
await page.getByRole("option", { name: label, exact: true }).click();
}
};
await choose(t.settingsTabBackup);
// Complete the real export after navigating back to the bottom action.
// Otherwise clicking General correctly dismisses the toast via click-away.
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(`${apiUrl}/export/account`, async route => {
// One native ZIP, replayed for viewport checks to respect the 2/minute owner limit.
exported ??= await route.fetch();
expect(exported.status()).toBe(200);
await held;
await route.fulfill({ response: exported });
}, { times: 1 });
const download = page.waitForEvent("download");
await main.getByRole("button", { name: t.accountExportDownload, exact: true }).click();
await choose(t.settingsTabGeneral);
const manage = main.getByRole("button", { name: t.settingsManageConnectedAccounts, exact: true });
await manage.focus();
release();
expect((await download).suggestedFilename()).toMatch(/\.zip$/);
const toast = page.getByRole("alert").filter({ hasText: t.accountExportDownloaded });
await expect(toast).toBeVisible();
// Deliberately align at the viewport end: reserved page space alone does
// not stop native scrolling from placing an action underneath the toast.
await manage.evaluate(node => node.scrollIntoView({ block: "end" }));
// Hover uses Snackbar's normal pause behavior; timing is not shortened or mocked.
await toast.hover();
await toast.getByRole("button").focus();
await exposed(manage);
await capture(page, info, `open-toast-${width}-${suffix}`);
await expect(toast).toBeVisible();
await exposed(toast.getByRole("button"));
await toast.getByRole("button").click();
await expect(toast).not.toBeVisible();
}
});
test(`[P5-01 PRES-E07 E08 A03] ${suffix} grouped shell, rail menu and route focus`, async ({ page }, info) => {
test.setTimeout(90_000);
const t = translations[language];
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/kanban");
const navigation = page.getByRole("navigation", { name: t.navigation });
await expect(navigation.getByText(t.jobSearch, { exact: true })).toBeVisible();
await expect(navigation.getByText(t.career, { exact: true })).toBeVisible();
await expect(navigation.getByRole("button", { name: t.jobApplications, exact: true })).toHaveAttribute("aria-current", "page");
await expect(page.getByRole("link", { name: t.jobsBoardView, exact: true })).toHaveAttribute("aria-current", "page");
await expect(navigation.getByRole("button", { name: t.connectedAccounts, exact: true })).toBeInViewport({ ratio: 1 });
await expect(page.getByRole("link", { name: t.skipToMainContent, exact: true })).toHaveAttribute("href", "#app-main-content");
await capture(page, info, `grouped-shell-${suffix}`);
await scan(page, info, `grouped-shell-${suffix}`, "nav");
await page.getByRole("button", { name: t.collapseSidebar, exact: true }).click();
const jobsRail = page.getByRole("button", { name: t.jobApplications, exact: true });
await jobsRail.focus();
await jobsRail.press("Enter");
const railMenu = page.getByRole("menu");
await expect(railMenu.getByRole("menuitem", { name: t.trash, exact: true })).toBeVisible();
await capture(page, info, `grouped-rail-${suffix}`);
await railMenu.getByRole("menuitem", { name: t.trash, exact: true }).click();
await expect(page).toHaveURL(/\/trash$/);
await expect(page.getByRole("heading", { level: 1, name: t.trash, exact: true })).toBeFocused();
await page.setViewportSize({ width: 390, height: 844 });
const trigger = page.getByRole("button", { name: t.openNavigation, exact: true });
await trigger.click();
const drawer = page.getByRole("dialog", { name: t.navigation });
await expect(drawer.getByText(t.jobSearch, { exact: true })).toBeVisible();
await expect(drawer.getByRole("button", { name: t.trash, exact: true })).toHaveAttribute("aria-current", "page");
await capture(page, info, `grouped-mobile-${suffix}`);
await scan(page, info, `grouped-mobile-${suffix}`, '[role="dialog"]');
await drawer.getByRole("button", { name: t.jobApplications, exact: true }).click();
await expect(page).toHaveURL(/\/jobs$/);
await expect(page.getByRole("heading", { level: 1, name: t.jobApplications, exact: true })).toBeFocused();
await expect(drawer).not.toBeVisible();
});
if ((mode === "light" && language === "en") || (mode === "dark" && language === "nb")) {
test(`[P5-00 PRES-E07 E09 C06 S02 S03] ${suffix} dirty editor survives shell utilities and cancel`, async ({ page, context, owner }, info) => {
test.setTimeout(90_000);
const t = translations[language];
await page.setViewportSize({ width: language === "nb" ? 320 : 1440, height: 900 });
await page.goto(`/career/builder/${owner.cvId}`);
await page.getByRole("tab", { name: t.cvEditorContent, exact: true }).click();
// A deterministic failed save keeps the real editor dirty; all reads stay native.
await page.route(`${apiUrl}/cv/variants/${owner.cvId}`, route => route.request().method() === "PUT"
? route.fulfill({ status: 503, json: { message: "Readiness save unavailable" } }) : route.continue());
const headline = () => page.getByRole("textbox", { name: translations[language].cvEditorHeadlineOverride, exact: true });
await headline().fill("Unsaved readiness draft æøå");
await expect(page.getByText(t.cvEditorSaveFailed, { exact: true })).toBeVisible();
const other = language === "en" ? "nb" : "en";
await page.locator("header").getByRole("button", { name: other === "nb" ? "Norsk" : "English", exact: true }).click();
await expect(page.getByRole("textbox", { name: translations[other].cvEditorHeadlineOverride, exact: true })).toHaveValue("Unsaved readiness draft æøå");
await page.locator("header").getByRole("button", { name: language === "nb" ? "Norsk" : "English", exact: true }).click();
await expect(headline()).toHaveValue("Unsaved readiness draft æøå");
// Change the existing browser preference through Settings in a second tab.
const settings = await context.newPage();
await settings.goto("/settings");
await settings.getByRole("combobox", { name: t.settingsTheme, exact: true }).click();
await settings.getByRole("option", { name: mode === "light" ? t.settingsThemeDark : t.settingsThemeLight, exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode === "light" ? "dark" : "light");
await settings.close();
await expect(headline()).toHaveValue("Unsaved readiness draft æøå");
if (language === "nb") {
const trigger = page.getByRole("button", { name: t.openNavigation });
await trigger.click();
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
await expect(headline()).toHaveValue("Unsaved readiness draft æøå");
await trigger.click();
await page.getByRole("dialog").getByRole("button", { name: t.dashboard, exact: true }).click();
} else {
await page.keyboard.press("Control+k");
await page.getByRole("dialog").getByRole("button", { name: new RegExp(t.goToDashboard) }).click();
}
const confirm = page.getByRole("dialog", { name: t.cvEditorDiscardTitle });
await expect(confirm).toBeVisible();
await capture(page, info, `dirty-cancel-${suffix}`);
await confirm.getByRole("button", { name: t.cancel, exact: true }).click();
await expect(headline()).toHaveValue("Unsaved readiness draft æøå");
await expect(page).toHaveURL(new RegExp(`/career/builder/${owner.cvId}$`));
await page.keyboard.press("Control+k");
await page.getByRole("dialog").getByRole("button", { name: new RegExp(t.goToDashboard) }).click();
await confirm.getByRole("button", { name: t.cvEditorDiscardLeave, exact: true }).click();
await expect(page).toHaveURL(/\/dashboard$/);
expect(JSON.stringify(await (await page.request.get(`${apiUrl}/cv/variants/${owner.cvId}`)).json())).not.toContain("Unsaved readiness draft");
});
}
});
}