699 lines
32 KiB
TypeScript
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);
|
|
});
|
|
});
|