Files
kandev/apps/web/e2e/tests/layout/workspace-picker-shortcut.spec.ts
T

147 lines
6.1 KiB
TypeScript

import { test, expect } from "../../fixtures/test-base";
import type { Page } from "@playwright/test";
import type { ApiClient } from "../../helpers/api-client";
import type { Workspace } from "../../../lib/types/http";
// WORKSPACE_PICKER (Cmd/Ctrl+E by default) opens the sidebar-header
// workspace switcher with focus already inside the menu, so a workspace can be
// picked with arrows + Enter alone. The shortcut runs on the global app-root
// listener (useAppShortcuts) and drives the picker through the store flag, so
// it also works while the sidebar is collapsed — the rail expands first.
const MODIFIER = process.platform === "darwin" ? "Meta" : "Control";
const WORKSPACE_ITEM_SELECTOR = '[data-testid^="sidebar-workspace-item-"]';
/** Clear focus so the shortcut isn't suppressed by a page-autofocused input. */
async function pressWorkspacePickerShortcut(page: Page): Promise<void> {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
await page.keyboard.press(`${MODIFIER}+e`);
}
/** Workspace ids in the order the open menu lists them. */
async function listedWorkspaceIds(page: Page): Promise<string[]> {
const testIds = await page
.locator(WORKSPACE_ITEM_SELECTOR)
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid") ?? ""));
return testIds.map((testId) => testId.replace("sidebar-workspace-item-", ""));
}
async function withDisposableWorkspace(
apiClient: ApiClient,
name: string,
run: (workspace: Workspace) => Promise<void>,
): Promise<void> {
const workspace = await apiClient.createWorkspace(name);
try {
await run(workspace);
} finally {
await apiClient.deleteWorkspace(workspace.id, workspace.name);
}
}
test.describe("Workspace picker shortcut (global)", () => {
test("opens the picker and switches workspace with arrows and Enter", async ({
testPage,
apiClient,
}) => {
await withDisposableWorkspace(apiClient, "Picker Shortcut Workspace", async (second) => {
await testPage.goto("/");
await expect(testPage.getByTestId("sidebar-workspace-trigger")).toBeVisible({
timeout: 10_000,
});
await pressWorkspacePickerShortcut(testPage);
await expect(testPage.getByTestId("sidebar-workspace-trigger")).toHaveAttribute(
"data-state",
"open",
);
const workspaceIds = await listedWorkspaceIds(testPage);
expect(workspaceIds).toContain(second.id);
expect(workspaceIds.length).toBeGreaterThanOrEqual(2);
// The shortcut opens a controlled menu. Wait for Radix's focus handoff
// before sending navigation keys, otherwise the first ArrowDown can land
// on the document body on a busy CI runner.
await expect(testPage.getByRole("menu")).toBeFocused();
// Focus lands on the menu itself, so the first ArrowDown moves to item 1
// and the second to item 2, with no mouse needed at any point.
await testPage.keyboard.press("ArrowDown");
await expect(testPage.getByTestId(`sidebar-workspace-item-${workspaceIds[0]}`)).toBeFocused();
await testPage.keyboard.press("ArrowDown");
await expect(testPage.getByTestId(`sidebar-workspace-item-${workspaceIds[1]}`)).toBeFocused();
await testPage.keyboard.press("Enter");
await expect(testPage).toHaveURL(
(url) => url.searchParams.get("workspaceId") === workspaceIds[1],
{ timeout: 10_000 },
);
await expect(testPage.locator(WORKSPACE_ITEM_SELECTOR).first()).not.toBeVisible();
});
});
test("expands a collapsed sidebar before opening the picker", async ({ testPage, apiClient }) => {
await withDisposableWorkspace(apiClient, "Collapsed Picker Workspace", async () => {
await testPage.goto("/");
const sidebar = testPage.getByTestId("app-sidebar");
await expect(sidebar).toBeVisible({ timeout: 10_000 });
await testPage.getByRole("button", { name: "Collapse sidebar" }).click();
await expect(sidebar).toHaveAttribute("data-collapsed", "true");
await pressWorkspacePickerShortcut(testPage);
await expect(sidebar).toHaveAttribute("data-collapsed", "false");
await expect(testPage.getByTestId("sidebar-workspace-trigger")).toHaveAttribute(
"data-state",
"open",
);
await expect(testPage.locator(WORKSPACE_ITEM_SELECTOR).first()).toBeVisible();
});
});
test("Escape closes the picker and leaves the workspace unchanged", async ({
testPage,
apiClient,
}) => {
await withDisposableWorkspace(apiClient, "Escape Picker Workspace", async () => {
await testPage.goto("/");
await expect(testPage.getByTestId("sidebar-workspace-trigger")).toBeVisible({
timeout: 10_000,
});
const urlBefore = testPage.url();
await pressWorkspacePickerShortcut(testPage);
await expect(testPage.locator(WORKSPACE_ITEM_SELECTOR).first()).toBeVisible();
await testPage.keyboard.press("Escape");
await expect(testPage.locator(WORKSPACE_ITEM_SELECTOR).first()).not.toBeVisible();
expect(testPage.url()).toBe(urlBefore);
// The store flag must have cleared on dismiss, or a second press would be
// a no-op (open -> open).
await pressWorkspacePickerShortcut(testPage);
await expect(testPage.locator(WORKSPACE_ITEM_SELECTOR).first()).toBeVisible();
});
});
test("does nothing below the sidebar's md boundary", async ({ testPage, apiClient }) => {
await withDisposableWorkspace(apiClient, "Narrow Viewport Workspace", async () => {
// 700px sits in the 640-767px band: the sidebar (and picker trigger) is
// display:none, but the menu portals to document.body — without the
// viewport guard it opened detached at the corner with a scroll lock.
await testPage.setViewportSize({ width: 700, height: 800 });
await testPage.goto("/");
await testPage.waitForLoadState("networkidle");
await pressWorkspacePickerShortcut(testPage);
await expect(testPage.locator(WORKSPACE_ITEM_SELECTOR).first()).not.toBeVisible();
await expect(testPage.locator("body")).not.toHaveAttribute("data-scroll-locked", /.+/);
});
});
});