Files
paseo/packages/app/e2e/support/helpers/sidebar.ts
T
Mohamed Boudra adf27de7d0 Keep Explorer consistent across phone, tablet and desktop layouts (#7424)
* Upgrade to Expo 57 and unify Explorer across layouts

* feat(app): retain Explorer across layout and appearance changes

Keep one workspace Explorer host and destination-aware navigation so folding, theme updates and compact sheets preserve the current panel. Scope pull request discovery and timeline demand to active surfaces.

* fix(app): keep Explorer resize handles stretched through the dock

The shared frame moved the divider into an absolute column, leaving its horizontal handle without height. Restore the row so pointer drags reach it, preserving the existing resize and performance contract.

* fix(app): keep Explorer drag widths out of the shared stylesheet

Carry the existing inline sizing policy into the shared frame so pointer previews do not accumulate CSS rules.

* fix(test): align isolated test runtimes with Expo 57

Hoisted React consumers need the matching DOM renderer at the repository root. Automated Metro processes must not install native DevTools into disposable homes, and development toast overlays must not cover compact actions.

Keep Explorer journeys on the shared host and synchronize setup fixtures on daemon output rather than a one-second file-marker window. A delayed real child start reproduces the readiness failure on every platform.

* fix(test): await retained shell layout after composer resize

The gesture host now stays mounted across breakpoints, so its presence no longer identifies compact layout. Report the current layout on the retained host and synchronize composer journeys with that state.

* fix(app): preserve browser interactions in retained workspace hosts

Initially disabled gesture hosts still registered pointer and wheel listeners, while hover cards used window positions inside a workspace-local sheet portal. Respect initial gesture enablement and position cards relative to their measured floating host.

Keep developer-toast dismissal independent of pointer input and replacement toast visibility so modal, hover, and focus checks retain their input state.

* fix(test): share developer toast handling with Electron browser runs

The standalone Electron browser runner bypassed the browser fixture, allowing developer errors to cover Settings. Use the same non-pointer dismissal in both runners while retaining their console logs.
2026-10-20 11:59:49 +07:00

298 lines
12 KiB
TypeScript

import { expect, type Page } from "@playwright/test";
import { getServerId } from "./server-id";
interface ContextMenuAnchor {
x: number;
y: number;
}
async function openContextMenuAtRowPoint(
page: Page,
rowTestID: string,
menuTestID: string,
): Promise<ContextMenuAnchor> {
const row = page.getByTestId(rowTestID);
await expect(row).toBeVisible({ timeout: 30_000 });
const bounds = await row.boundingBox();
if (!bounds) throw new Error(`Could not measure ${rowTestID}`);
const anchor = {
x: bounds.x + Math.min(60, bounds.width / 2),
y: bounds.y + bounds.height / 2,
};
await page.mouse.click(anchor.x, anchor.y, { button: "right" });
await expect(page.getByTestId(menuTestID)).toBeVisible({ timeout: 10_000 });
return anchor;
}
async function expectContextMenuAtPointer(
page: Page,
menuTestID: string,
anchor: ContextMenuAnchor,
): Promise<void> {
const bounds = await page.getByTestId(menuTestID).boundingBox();
if (!bounds) throw new Error(`Could not measure ${menuTestID}`);
expect(Math.abs(bounds.x - anchor.x)).toBeLessThanOrEqual(2);
expect(Math.abs(bounds.y - (anchor.y + 4))).toBeLessThanOrEqual(2);
}
export async function openWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
const workspaceKey = `${getServerId()}:${workspaceId}`;
const menuTestID = `sidebar-workspace-context-menu-${workspaceKey}`;
const anchor = await openContextMenuAtRowPoint(
page,
`sidebar-workspace-row-${workspaceKey}`,
menuTestID,
);
await expectContextMenuAtPointer(page, menuTestID, anchor);
}
export async function showWorkspaceHoverCard(page: Page, workspaceId: string): Promise<void> {
const workspaceKey = `${getServerId()}:${workspaceId}`;
await page.getByTestId(`sidebar-workspace-row-${workspaceKey}`).hover();
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
}
export async function movePointerAwayFromWorkspaceHoverCard(page: Page): Promise<void> {
// Two moves, so the pointer always moves even when it is already parked here.
await page.mouse.move(1200, 200, { steps: 5 });
await page.mouse.move(1200, 400, { steps: 5 });
}
export async function focusWorkspaceRowWithKeyboard(
page: Page,
workspaceId: string,
): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await row.focus();
await page.keyboard.press("Shift+Tab");
await page.keyboard.press("Tab");
await expect(row).toBeFocused();
}
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
}
export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<void> {
// Longer than the hover card's close grace period.
await page.waitForTimeout(300);
await expectWorkspaceHoverCardOpen(page);
}
export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
}
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
const workspaceKey = `${getServerId()}:${workspaceId}`;
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
}
export async function expectWorkspaceContextMenuOwnsAttention(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
}
export async function expectWorkspaceRowHoverCleared(
page: Page,
workspaceId: string,
): Promise<void> {
const workspaceKey = `${getServerId()}:${workspaceId}`;
await expect(page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`)).toBeHidden();
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
}
export async function expectWorkspaceContextMenuActions(
page: Page,
workspaceId: string,
): Promise<void> {
const workspaceKey = `${getServerId()}:${workspaceId}`;
const menu = page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}`);
await expect(menu.getByRole("menuitem", { name: "Copy path" })).toBeVisible();
await expect(menu.getByRole("menuitem", { name: "Rename workspace" })).toBeVisible();
await expect(menu.getByRole("menuitem", { name: "Archive" })).toBeVisible();
}
export async function openProjectContextMenu(page: Page, projectViewKey: string): Promise<void> {
const menuTestID = `sidebar-project-context-menu-${projectViewKey}`;
const anchor = await openContextMenuAtRowPoint(
page,
`sidebar-project-row-${projectViewKey}`,
menuTestID,
);
await expectContextMenuAtPointer(page, menuTestID, anchor);
}
export async function closeProjectContextMenu(page: Page, projectViewKey: string): Promise<void> {
await page.getByTestId(`sidebar-project-context-menu-${projectViewKey}-backdrop`).click();
}
export async function expectProjectContextMenuActions(
page: Page,
projectViewKey: string,
): Promise<void> {
const menu = page.getByTestId(`sidebar-project-context-menu-${projectViewKey}`);
await expect(menu.getByRole("menuitem", { name: "Open project settings" })).toBeVisible();
await expect(menu.getByRole("menuitem", { name: "Remove project" })).toBeVisible();
}
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
}
async function openWorkspaceSidebarKebab(page: Page, workspaceId: string) {
const serverId = getServerId();
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.hover();
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
return serverId;
}
export async function expectWorkspaceListed(page: Page, name: string): Promise<void> {
await expect(
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
).toBeVisible({ timeout: 30_000 });
}
// The workspace row kebab and its menu items carry no web ARIA role, so the sidebar
// suite addresses them by the stable test ids the app assigns per workspace — the same
// convention the rename flow uses. The kebab only reveals on hover.
export async function clickArchiveWorkspaceMenuItem(
page: Page,
workspaceId: string,
): Promise<void> {
const serverId = await openWorkspaceSidebarKebab(page, workspaceId);
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
await archiveItem.click();
}
export async function pinWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
const serverId = await openWorkspaceSidebarKebab(page, workspaceId);
const pinItem = page.getByTestId(`sidebar-workspace-menu-pin-${serverId}:${workspaceId}`);
await expect(pinItem).toBeVisible({ timeout: 10_000 });
await pinItem.click();
}
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
// A clean workspace archives with no prompt. Managed worktree backing may raise
// a browser confirm for unsynced work, so accept it when present.
page.once("dialog", (dialog) => void dialog.accept());
await clickArchiveWorkspaceMenuItem(page, workspaceId);
}
export async function expectWorkspaceAbsentFromSidebar(
page: Page,
workspaceId: string,
): Promise<void> {
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`),
).toHaveCount(0, { timeout: 30_000 });
}
// The display-preferences menu is one row per decision, and the options sit a page below that
// row. Every caller has to walk the same path, so it lives here: when the menu's shape moves
// again, this is the only place that has to follow.
export async function openSidebarDisplayPage(page: Page, branchTestID: string): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId(branchTestID).click();
}
// Project filters live a page below the display-preferences root, like the host filters.
export async function openSidebarProjectFilter(page: Page): Promise<void> {
await openSidebarDisplayPage(page, "sidebar-display-project-filter");
}
// The filter rows are keyed by the project's sidebar view key. A project seeded from its own temp
// repo has a `projectKey` unique on the host, so the view key IS that key — see
// `buildWorkspaceStructureProjects`.
export async function toggleProjectFilter(page: Page, projectViewKey: string): Promise<void> {
await page.getByTestId(`sidebar-project-filter-${projectViewKey}`).click();
}
export async function selectAllProjectsFilter(page: Page): Promise<void> {
await page.getByTestId("sidebar-project-filter-all").click();
}
export async function closeSidebarDisplayPreferences(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("sidebar-display-preferences-content")).toHaveCount(0);
}
export async function selectSidebarStatusGrouping(page: Page): Promise<void> {
await openSidebarDisplayPage(page, "sidebar-display-grouping");
await page.getByTestId("sidebar-grouping-status").click();
}
export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu" }).click();
}
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
const closeButton = page.getByTestId("sidebar-close");
await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 });
await closeButton.click();
}
// The mobile sidebar panel animates via translateX. Waiting for its header to be fully visible
// prevents a close click from targeting a button while the panel is still moving.
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ ratio: 1, timeout: 5_000 });
}
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
}
/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */
export async function swipeOpenMobileAgentSidebar(page: Page): Promise<void> {
const viewport = page.viewportSize();
if (!viewport) throw new Error("viewport size is required");
const y = Math.round(viewport.height / 2);
await page.mouse.move(8, y);
await page.mouse.down();
await page.mouse.move(40, y, { steps: 4 });
await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
await page.mouse.up();
}
/** Drag from the right edge to open the compact Explorer over the agent. */
export async function swipeOpenMobileExplorer(page: Page): Promise<void> {
const viewport = page.viewportSize();
if (!viewport) throw new Error("viewport size is required");
const y = Math.round(viewport.height / 2);
await page.mouse.move(viewport.width - 8, y);
await page.mouse.down();
await page.mouse.move(viewport.width - 40, y, { steps: 4 });
await page.mouse.move(Math.round(viewport.width * 0.3), y, { steps: 12 });
await page.mouse.up();
}
export async function closeMobileExplorer(page: Page): Promise<void> {
const closeButton = page
.getByTestId("workspace-explorer-sidebar")
.getByRole("button", { name: "Close Explorer sidebar", exact: true });
await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 });
await closeButton.click();
}
export async function expectMobileExplorerVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeInViewport({
ratio: 1,
timeout: 5_000,
});
}
export async function expectMobileExplorerHidden(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeInViewport({
timeout: 5_000,
});
}