Files
HiveGrid-Wireframes/hivegrid-cloud/tests/e2e/mobile-authenticated-shell.spec.ts
T

699 lines
32 KiB
TypeScript

import { expect, type Page, test } from "./utils/test";
import { getTenantCredentials, loginAsTenantUser, tenantSettingsAccountPath, tenantSettingsProfilePath, tenantSettingsSubscriptionPath } from "./utils/auth";
import { expectNoCriticalA11yViolations } from "./utils/axe";
import { compactMobileViewport, expectNoHorizontalOverflow, narrowMobileViewport } from "./utils/mobile";
import { escapeRegExp, skipIfUnavailable } from "./utils/navigation";
import { skipOrFailWhenE2eFixtureMissing, skipOrFailWhenE2eInputsMissing, skipOrFailWhenE2eReadinessOptInMissing } from "./utils/readiness";
const credentials = getTenantCredentials();
const dashboardPath = process.env.E2E_TENANT_DASHBOARD_PATH ?? "/app/dashboard";
const pluginSettingsPath = process.env.E2E_PLUGIN_SETTINGS_PATH;
const workflowBuilderPath = process.env.E2E_WORKFLOW_BUILDER_PATH;
const workflowRunManualPath = process.env.E2E_WORKFLOW_RUN_MANUAL_PATH;
const mobileSidebarSelector = '[data-ui-id="ui.surface.app.sidebar.mobile"]';
type MobileShellRouteCheck = {
name: string;
path: string;
readySelector?: string;
};
function requireMobileShellInputs(reason: string): void {
skipOrFailWhenE2eInputsMissing(["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD"], reason);
}
async function expectFocusInsideMobileSidebar(page: Page, stepName: string): Promise<void> {
await expect
.poll(
async () =>
page.locator(mobileSidebarSelector).evaluate((sidebar) => {
const activeElement = document.activeElement;
return Boolean(activeElement && sidebar.contains(activeElement));
}),
{ message: `focus remains inside the mobile sidebar after ${stepName}` }
)
.toBe(true);
}
async function countMobileSidebarFocusableItems(page: Page): Promise<number> {
return page.locator(mobileSidebarSelector).evaluate((sidebar) => {
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",");
return Array.from(sidebar.querySelectorAll<HTMLElement>(focusableSelector)).filter((element) => {
const style = window.getComputedStyle(element);
return style.visibility !== "hidden" && style.display !== "none" && element.offsetParent !== null;
}).length;
});
}
async function expectMobileShellControlsUsable(page: Page): Promise<void> {
const sidebarTrigger = page.locator('[data-ui-id="ui.action.shell.sidebarTrigger"]');
const moreTrigger = page.locator('[data-ui-id="ui.action.shell.more"]');
const mobileSidebar = page.locator(mobileSidebarSelector);
await expect(sidebarTrigger).toBeVisible();
await expect(moreTrigger).toBeVisible();
await expectNoHorizontalOverflow(page);
// A route change fires the sidebar's setOpenMobile(false) effect; a single
// click can race that close and leave the drawer shut. Retry-open, only
// clicking while it's closed, so the toggle never clobbers an open drawer.
await expect(async () => {
if (!(await mobileSidebar.isVisible())) {
await sidebarTrigger.click();
}
await expect(mobileSidebar).toBeVisible({ timeout: 2000 });
}).toPass({ timeout: 15000 });
await expect(mobileSidebar.locator('[data-ui-id="ui.nav.app.dashboard"]').first()).toBeVisible();
await expect(mobileSidebar.locator('[data-ui-id="ui.nav.app.settings"]').first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(mobileSidebar).toBeHidden();
}
async function expectAuthenticatedMobileShellRoute(page: Page, routeCheck: MobileShellRouteCheck): Promise<void> {
await expect(page).toHaveURL(pathPattern(routeCheck.path));
await expect(page).not.toHaveURL(/\/login(?:[?#].*)?$/u);
if (routeCheck.readySelector) {
await expect(page.locator(routeCheck.readySelector).first()).toBeVisible({ timeout: 10_000 });
}
await expectMobileShellControlsUsable(page);
}
async function gotoMobileShellRoute(page: Page, routeCheck: MobileShellRouteCheck): Promise<void> {
const response = await page.goto(routeCheck.path, { waitUntil: "domcontentloaded" });
expect(response?.status() ?? 200).toBeLessThan(500);
await skipIfUnavailable(page, response, `${routeCheck.name} path ${routeCheck.path}`);
await expectAuthenticatedMobileShellRoute(page, routeCheck);
}
async function expectCrossSurfaceHistoryResizeReload(page: Page, originRoute: MobileShellRouteCheck, targetRoute: MobileShellRouteCheck): Promise<void> {
await gotoMobileShellRoute(page, originRoute);
await gotoMobileShellRoute(page, targetRoute);
await page.goBack({ waitUntil: "domcontentloaded" });
await expectAuthenticatedMobileShellRoute(page, originRoute);
await page.goForward({ waitUntil: "domcontentloaded" });
await expectAuthenticatedMobileShellRoute(page, targetRoute);
await page.setViewportSize(compactMobileViewport);
await expectAuthenticatedMobileShellRoute(page, targetRoute);
await page.setViewportSize(narrowMobileViewport);
const response = await page.reload({ waitUntil: "domcontentloaded" });
expect(response?.status() ?? 200).toBeLessThan(500);
await skipIfUnavailable(page, response, `${targetRoute.name} reload ${targetRoute.path}`);
await expectAuthenticatedMobileShellRoute(page, targetRoute);
}
function pathPattern(path: string): RegExp {
return new RegExp(`${escapeRegExp(path)}(?:[?#].*)?$`, "u");
}
test.describe("authenticated mobile shell flow", () => {
test.use({ viewport: narrowMobileViewport, isMobile: true, hasTouch: true });
test.beforeEach(() => {
requireMobileShellInputs("Authenticated mobile shell flow inputs not configured");
});
test("opens the mobile sidebar and navigates from dashboard to settings", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
const mobileSidebar = page.locator('[data-ui-id="ui.surface.app.sidebar.mobile"], [data-mobile="true"]');
await expect(mobileSidebar).toBeHidden();
await page.locator('[data-ui-id="ui.action.shell.sidebarTrigger"]').click();
await expect(mobileSidebar).toBeVisible();
await expect(mobileSidebar).toHaveAttribute("role", "dialog");
await expect(mobileSidebar).toHaveAccessibleName(/.+/u);
await expect(mobileSidebar).toHaveAccessibleDescription(/.+/u);
const settingsLink = mobileSidebar.locator('[data-ui-id="ui.nav.app.settings"]').first();
await expect(settingsLink).toBeVisible();
await settingsLink.click();
const settingsProfilePath = tenantSettingsProfilePath(dashboardPath);
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(settingsProfilePath)}(?:[?#].*)?$`, "u"));
await expect(mobileSidebar).toBeHidden();
await expectNoHorizontalOverflow(page);
});
test("closes the mobile sidebar with Escape without losing shell navigation", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
const trigger = page.locator('[data-ui-id="ui.action.shell.sidebarTrigger"]');
const mobileSidebar = page.locator('[data-ui-id="ui.surface.app.sidebar.mobile"], [data-mobile="true"]');
await expect(mobileSidebar).toBeHidden();
await trigger.click();
await expect(mobileSidebar).toBeVisible();
await expect(mobileSidebar.locator('[data-ui-id="ui.nav.app.dashboard"]').first()).toBeVisible();
await expect(mobileSidebar.locator('[data-ui-id="ui.nav.app.settings"]').first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(mobileSidebar).toBeHidden();
await expect(trigger).toBeVisible();
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
});
test("traps keyboard focus inside the mobile sidebar and returns focus to the trigger", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
const trigger = page.locator('[data-ui-id="ui.action.shell.sidebarTrigger"]');
const mobileSidebar = page.locator(mobileSidebarSelector);
await expect(mobileSidebar).toBeHidden();
await trigger.focus();
await expect(trigger).toBeFocused();
await page.keyboard.press("Enter");
await expect(mobileSidebar).toBeVisible();
await expectFocusInsideMobileSidebar(page, "opening");
const focusableItemCount = await countMobileSidebarFocusableItems(page);
expect(focusableItemCount).toBeGreaterThan(0);
for (let index = 0; index < focusableItemCount + 2; index += 1) {
await page.keyboard.press("Tab");
await expectFocusInsideMobileSidebar(page, `Tab ${index + 1}`);
}
await page.keyboard.press("Escape");
await expect(mobileSidebar).toBeHidden();
await expect(trigger).toBeVisible();
await expect(trigger).toBeFocused();
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
});
test("keyboard shortcut toggles the mobile sidebar without changing route", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
const mobileSidebar = page.locator('[data-mobile="true"]');
await expect(mobileSidebar).toBeHidden();
await page.keyboard.press("Control+e");
await expect(mobileSidebar).toBeVisible();
await expect(mobileSidebar.locator('[data-ui-id="ui.nav.app.dashboard"]').first()).toBeVisible();
await expect(mobileSidebar.locator('[data-ui-id="ui.nav.app.settings"]').first()).toBeVisible();
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await page.keyboard.press("Control+e");
await expect(mobileSidebar).toBeHidden();
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
});
test("mobile more menu exposes profile and sign-out contracts without submitting", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
await page.locator('[data-ui-id="ui.action.shell.more"]').click();
const profileTarget = page.locator('[data-ui-id="ui.action.shell.profile"]').first();
await expect(profileTarget).toBeVisible();
await expect(profileTarget.locator("xpath=ancestor::a[1]")).toHaveAttribute("href", /settings\/profile/u);
const signOut = page.locator('[data-ui-id="ui.action.shell.signOut"]').first();
await expect(signOut).toBeVisible();
await expect(signOut.locator("xpath=ancestor::form[1]")).toHaveAttribute("action", /\/logout$/u);
await expectNoCriticalA11yViolations(page);
await expectNoHorizontalOverflow(page);
});
test("mobile more menu closes with Escape and returns control to the trigger", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
const trigger = page.locator('[data-ui-id="ui.action.shell.more"]');
await trigger.click();
const signOut = page.locator('[data-ui-id="ui.action.shell.signOut"]').first();
await expect(signOut).toBeVisible();
await page.keyboard.press("Escape");
await expect(signOut).toBeHidden();
await expect(trigger).toBeVisible();
await expect(trigger).toBeFocused();
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
});
test("mobile command palette focuses search and closes with Escape", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
const trigger = page.locator('[data-ui-id="ui.action.shell.search"]');
await trigger.click();
const palette = page.locator('[data-ui-id="ui.surface.commandPalette.portal"]');
const searchInput = page.locator('[data-ui-id="ui.surface.commandPalette.search"]');
await expect(palette).toBeVisible();
await expect(searchInput).toBeVisible();
await expect(searchInput).toBeFocused();
await searchInput.fill("settings");
await page.keyboard.press("Escape");
await expect(palette).toBeHidden();
await expect(trigger).toBeVisible();
await expect(trigger).toBeFocused();
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
});
test("mobile command palette navigates from a selected result", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
await page.locator('[data-ui-id="ui.action.shell.search"]').click();
const palette = page.locator('[data-ui-id="ui.surface.commandPalette.portal"]');
const searchInput = page.locator('[data-ui-id="ui.surface.commandPalette.search"]');
const rows = page.locator('[data-ui-id="ui.surface.commandPalette.row"]');
await expect(palette).toBeVisible();
await expect(searchInput).toBeFocused();
await searchInput.fill("settings");
await expect(rows.first()).toBeVisible();
await rows.first().click();
await expect(palette).toBeHidden();
await expect(page).toHaveURL(/\/settings(?:\/[^?#]+)?(?:[?#].*)?$/u);
await expectNoCriticalA11yViolations(page);
await expectNoHorizontalOverflow(page);
});
test("mobile command palette supports keyboard-only result selection", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
await page.keyboard.press("Control+k");
const palette = page.locator('[data-ui-id="ui.surface.commandPalette.portal"]');
const searchInput = page.locator('[data-ui-id="ui.surface.commandPalette.search"]');
const rows = page.locator('[data-ui-id="ui.surface.commandPalette.row"]');
await expect(palette).toBeVisible();
await expect(searchInput).toBeVisible();
await expect(searchInput).toBeFocused();
await searchInput.fill("settings");
await expect(rows.first()).toBeVisible();
await page.keyboard.press("Enter");
await expect(palette).toBeHidden();
await expect(page).toHaveURL(/\/settings(?:\/[^?#]+)?(?:[?#].*)?$/u);
await expectNoCriticalA11yViolations(page);
await expectNoHorizontalOverflow(page);
});
test("signs out from the mobile more menu and protects the dashboard", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await page.locator('[data-ui-id="ui.action.shell.more"]').click();
const signOut = page.locator('[data-ui-id="ui.action.shell.signOut"]').first();
await expect(signOut).toBeVisible();
await signOut.click();
// Sign-out posts /logout.data via single-fetch and client-redirects to /login.
// Wait for that landing so the cleared session cookie is committed before the
// dashboard re-check — otherwise the next navigation races the logout response.
await page.waitForURL(/\/login(?:[?#].*)?/u, { timeout: 15_000 });
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/login(?:[?#].*)?/u);
expect(new URL(page.url()).searchParams.get("redirectTo") ?? "").toContain(dashboardPath);
});
test("keeps account settings accessible on mobile and returns to dashboard", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
await page.goto(tenantSettingsAccountPath(dashboardPath), { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(tenantSettingsAccountPath(dashboardPath))}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
const mobileSidebar = page.locator('[data-mobile="true"]');
await expect(mobileSidebar).toBeHidden();
await page.locator('[data-ui-id="ui.action.shell.sidebarTrigger"]').click();
await expect(mobileSidebar).toBeVisible();
const dashboardLink = mobileSidebar.locator('[data-ui-id="ui.nav.app.dashboard"]').first();
await expect(dashboardLink).toBeVisible();
await dashboardLink.click();
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expect(mobileSidebar).toBeHidden();
await expectNoHorizontalOverflow(page);
});
test("keeps profile settings selectors stable on mobile without submitting", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const settingsProfilePath = tenantSettingsProfilePath(dashboardPath);
await page.goto(settingsProfilePath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(settingsProfilePath)}(?:[?#].*)?$`, "u"));
await expect(page.locator('[data-ui-id="ui.settings.profile.page"]').first()).toBeVisible();
const form = page.locator('[data-ui-id="ui.settings.profile.form"]').first();
await expect(form).toBeVisible();
await expect(form.locator('input[name="action"][value="profile"]')).toHaveCount(1);
const email = form.locator('[data-ui-id="ui.settings.profile.email"]').first();
await expect(email).toBeVisible();
await expect(email).toBeDisabled();
await expect(email).not.toHaveValue("");
await expect(form.locator('[data-ui-id="ui.settings.profile.firstName"]').first()).toBeVisible();
await expect(form.locator('[data-ui-id="ui.settings.profile.lastName"]').first()).toBeVisible();
await expect(form.locator('[data-ui-id="ui.settings.profile.save"]').first()).toBeVisible();
await expect(page.locator('[data-ui-id="ui.settings.profile.session.panel"]').first()).toBeVisible();
await expect(page.locator('[data-ui-id="ui.settings.profile.danger.delete"]').first()).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test("keeps the mobile shell usable after hard reload on nested authenticated routes", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const routeChecks = [
{ name: "account settings", path: tenantSettingsAccountPath(dashboardPath) },
{ name: "profile settings", path: tenantSettingsProfilePath(dashboardPath) },
{ name: "subscription settings", path: tenantSettingsSubscriptionPath(dashboardPath), readySelector: '[data-ui-id="ui.subscription.page"]' },
...(pluginSettingsPath ? [{ name: "plugin settings", path: pluginSettingsPath }] : []),
...(workflowBuilderPath ? [{ name: "workflow builder", path: workflowBuilderPath }] : []),
];
for (const routeCheck of routeChecks) {
await test.step(routeCheck.name, async () => {
await page.goto(routeCheck.path, { waitUntil: "domcontentloaded" });
await expect(page).not.toHaveURL(/\/login(?:[?#].*)?$/u);
if ("readySelector" in routeCheck) {
await expect(page.locator(routeCheck.readySelector).first()).toBeVisible();
}
const response = await page.reload({ waitUntil: "domcontentloaded" });
expect(response?.status() ?? 200).toBeLessThan(500);
await expect(page).not.toHaveURL(/\/login(?:[?#].*)?$/u);
if ("readySelector" in routeCheck) {
await expect(page.locator(routeCheck.readySelector).first()).toBeVisible();
}
const sidebarTrigger = page.locator('[data-ui-id="ui.action.shell.sidebarTrigger"]');
const moreTrigger = page.locator('[data-ui-id="ui.action.shell.more"]');
const mobileSidebar = page.locator(mobileSidebarSelector);
await expect(sidebarTrigger).toBeVisible();
await expect(moreTrigger).toBeVisible();
await expectNoHorizontalOverflow(page);
await sidebarTrigger.click();
await expect(mobileSidebar).toBeVisible();
await expect(mobileSidebar.locator('[data-ui-id="ui.nav.app.dashboard"]').first()).toBeVisible();
await expect(mobileSidebar.locator('[data-ui-id="ui.nav.app.settings"]').first()).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.keyboard.press("Escape");
await expect(mobileSidebar).toBeHidden();
await moreTrigger.click();
await expect(page.locator('[data-ui-id="ui.action.shell.signOut"]').first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(moreTrigger).toBeFocused();
await expectNoCriticalA11yViolations(page);
});
}
});
test("keeps authenticated new-account creation form accessible on mobile without submitting", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const newAccountPath = process.env.E2E_NEW_ACCOUNT_PATH ?? "/new-account";
await page.goto(newAccountPath, { waitUntil: "domcontentloaded" });
await expect(page).not.toHaveURL(/\/login(?:[?#].*)?$/u);
await expect(page.locator('form input[name="name"]').first()).toBeVisible();
await expect(page.locator('form input[name="action"][value="create"]').first()).toHaveCount(1);
const csrfInput = page.locator('form input[name="csrf"]').first();
if ((await csrfInput.count()) > 0) {
expect(await csrfInput.inputValue()).not.toBe("");
}
await expect(page.locator('form button[type="submit"]').first()).toBeVisible();
await expect(page.locator('form button[type="submit"]').first()).toBeEnabled();
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
});
test("submits new-account form contract without creating a tenant", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
const newAccountPath = process.env.E2E_NEW_ACCOUNT_PATH ?? "/new-account";
await page.goto(newAccountPath, { waitUntil: "domcontentloaded" });
await expect(page).not.toHaveURL(/\/login(?:[?#].*)?$/u);
const form = page.locator("form").first();
const nameInput = form.locator('input[name="name"]').first();
const actionInput = form.locator('input[name="action"][value="create"]').first();
const csrfInput = form.locator('input[name="csrf"]').first();
const submit = form.locator('button[type="submit"]').first();
if ((await nameInput.count()) === 0 || (await submit.count()) === 0) {
skipOrFailWhenE2eFixtureMissing("New-account creation form is not rendered in this environment");
}
await expect(nameInput).toBeVisible();
await expect(actionInput).toHaveCount(1);
await expect(csrfInput).not.toHaveValue("");
await expect(submit).toBeEnabled();
const currentPathname = new URL(page.url()).pathname;
let resolveSubmitted!: (formData: URLSearchParams) => void;
const submitted = new Promise<URLSearchParams>((resolve) => {
resolveSubmitted = resolve;
});
await page.route("**/*", async (route) => {
const request = route.request();
const requestUrl = new URL(request.url());
// React Router single-fetch posts form actions to `${pathname}.data`.
const matchesRoute = requestUrl.pathname === currentPathname || requestUrl.pathname === `${currentPathname}.data`;
if (request.method() !== "POST" || !matchesRoute) {
await route.continue();
return;
}
resolveSubmitted(new URLSearchParams(request.postData() ?? ""));
await route.fulfill({
status: 204,
body: "",
});
});
await nameInput.fill("E2E Contract Account");
await submit.click();
const formData = await submitted;
expect(formData.get("action")).toBe("create");
expect(formData.get("name")).toBe("E2E Contract Account");
expect(formData.get("csrf") ?? "").not.toBe("");
});
test("skip link moves keyboard focus to the main app region", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await page.keyboard.press("Tab");
const skipLink = page.getByTestId("skip-to-content");
await expect(skipLink).toBeVisible();
await expect(skipLink).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.locator('[data-ui-id="ui.surface.shell.main"]')).toBeFocused();
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
});
});
test.describe("authenticated mobile shell browser history slice", () => {
test.use({ viewport: narrowMobileViewport, isMobile: true, hasTouch: true });
test.beforeEach(() => {
requireMobileShellInputs("Authenticated mobile shell browser history inputs not configured");
skipOrFailWhenE2eReadinessOptInMissing("E2E_BROWSER_MATRIX", "skipping opt-in mobile shell browser history slice");
});
test("keeps shell usable across sidebar navigation, browser history, resize, and reload", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(pathPattern(dashboardPath));
await expectMobileShellControlsUsable(page);
const settingsProfilePath = tenantSettingsProfilePath(dashboardPath);
const mobileSidebar = page.locator(mobileSidebarSelector);
await page.locator('[data-ui-id="ui.action.shell.sidebarTrigger"]').click();
await expect(mobileSidebar).toBeVisible();
await mobileSidebar.locator('[data-ui-id="ui.nav.app.settings"]').first().click();
await expect(page).toHaveURL(pathPattern(settingsProfilePath));
await expect(mobileSidebar).toBeHidden();
await expectMobileShellControlsUsable(page);
await page.goBack({ waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(pathPattern(dashboardPath));
await expectMobileShellControlsUsable(page);
await page.goForward({ waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(pathPattern(settingsProfilePath));
await expectMobileShellControlsUsable(page);
await page.setViewportSize(compactMobileViewport);
await expectMobileShellControlsUsable(page);
await page.setViewportSize(narrowMobileViewport);
const response = await page.reload({ waitUntil: "domcontentloaded" });
expect(response?.status() ?? 200).toBeLessThan(500);
await expect(page).toHaveURL(pathPattern(settingsProfilePath));
await expectMobileShellControlsUsable(page);
const originRoute = { name: "profile settings", path: settingsProfilePath, readySelector: '[data-ui-id="ui.settings.profile.page"]' };
const crossSurfaceRoutes = [
{ name: "subscription settings", path: tenantSettingsSubscriptionPath(dashboardPath), readySelector: '[data-ui-id="ui.subscription.page"]' },
...(pluginSettingsPath ? [{ name: "plugin settings", path: pluginSettingsPath, readySelector: '[data-ui-id="ui.plugins.page"]' }] : []),
...(workflowBuilderPath
? [{ name: "workflow builder", path: workflowBuilderPath, readySelector: '[data-ui-id="ui.workflows.builder.canvas"]' }]
: []),
...(workflowRunManualPath
? [{ name: "workflow manual run", path: workflowRunManualPath, readySelector: '[data-ui-id="ui.workflows.run.manual.form"]' }]
: []),
];
for (const targetRoute of crossSurfaceRoutes) {
await test.step(`keeps shell usable across ${targetRoute.name} history and resize`, async () => {
await expectCrossSurfaceHistoryResizeReload(page, originRoute, targetRoute);
});
}
await expectNoCriticalA11yViolations(page);
});
});
test.describe("authenticated compact mobile shell flow", () => {
test.use({ viewport: compactMobileViewport, isMobile: true, hasTouch: true });
test.beforeEach(() => {
requireMobileShellInputs("Authenticated compact mobile shell flow inputs not configured");
});
test("keeps dashboard and settings shell controls usable at 320px", async ({ page }) => {
await loginAsTenantUser(page, credentials!);
await page.goto(dashboardPath, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(`${escapeRegExp(dashboardPath)}(?:[?#].*)?$`, "u"));
await expectNoHorizontalOverflow(page);
const sidebarTrigger = page.locator('[data-ui-id="ui.action.shell.sidebarTrigger"]');
const mobileSidebar = page.locator(mobileSidebarSelector);
await sidebarTrigger.click();
await expect(mobileSidebar).toBeVisible();
await expect(mobileSidebar.locator('[data-ui-id="ui.nav.app.dashboard"]').first()).toBeVisible();
await expect(mobileSidebar.locator('[data-ui-id="ui.nav.app.settings"]').first()).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.keyboard.press("Escape");
await expect(mobileSidebar).toBeHidden();
await expect(sidebarTrigger).toBeFocused();
await expectNoHorizontalOverflow(page);
const moreTrigger = page.locator('[data-ui-id="ui.action.shell.more"]');
await moreTrigger.click();
const signOut = page.locator('[data-ui-id="ui.action.shell.signOut"]').first();
await expect(signOut).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.keyboard.press("Escape");
await expect(signOut).toBeHidden();
await expect(moreTrigger).toBeFocused();
await expectNoHorizontalOverflow(page);
const searchTrigger = page.locator('[data-ui-id="ui.action.shell.search"]');
await searchTrigger.click();
const palette = page.locator('[data-ui-id="ui.surface.commandPalette.portal"]');
const searchInput = page.locator('[data-ui-id="ui.surface.commandPalette.search"]');
await expect(palette).toBeVisible();
await expect(searchInput).toBeFocused();
await searchInput.fill("settings");
await expect(page.locator('[data-ui-id="ui.surface.commandPalette.row"]').first()).toBeVisible();
await expectNoCriticalA11yViolations(page);
await expectNoHorizontalOverflow(page);
await page.keyboard.press("Escape");
await expect(palette).toBeHidden();
await expect(searchTrigger).toBeFocused();
await expectNoHorizontalOverflow(page);
const settingsProfilePath = tenantSettingsProfilePath(dashboardPath);
await sidebarTrigger.click();
await expect(mobileSidebar).toBeVisible();
await mobileSidebar.locator('[data-ui-id="ui.nav.app.settings"]').first().click();
await expect(page).toHaveURL(pathPattern(settingsProfilePath));
await expect(mobileSidebar).toBeHidden();
await expectNoHorizontalOverflow(page);
// Reopening the drawer right after the settings SPA navigation can race the
// closing Sheet's state, dropping the first trigger click. Retry the open
// (only clicking while it is still closed, so we never toggle it back shut).
await expect(async () => {
if (!(await mobileSidebar.isVisible())) {
await sidebarTrigger.click();
}
await expect(mobileSidebar).toBeVisible({ timeout: 2000 });
}).toPass({ timeout: 15000 });
await page.keyboard.press("Escape");
await expect(mobileSidebar).toBeHidden();
await expect(sidebarTrigger).toBeFocused();
await expectNoHorizontalOverflow(page);
await expectNoCriticalA11yViolations(page);
});
});