* fix(app): keep an open workspace menu from unmounting its own trigger The sidebar kebab is revealed by hover, and on a compact layout the menu it opens is a sheet that slides up over the row it came from. The row un-hovers, the trailing overlay unmounts the trigger, and the menu's open state goes with it — nothing is left that can close the surface already presented in the portal, so a full-screen backdrop swallows every click until reload. Lift the menu's open state to the component that decides whether to render the kebab, and keep the kebab rendered while the menu is up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(app): follow the sidebar and host badge redesigns in the e2e suite Three specs were still driving surfaces that moved out from under them, so CI has been red on main since the sidebar polish landed. Display preferences became one row per decision with the options a page down, so grouping and the host filter need that page walked first. Five specs had their own copy of the two-line dance; it lives in the sidebar helper now, and the host filter has its own opener next to the menu it belongs to. The host badge is plain secondary text on the meta line and its identity colour lives on the server icon alone, so the helpers stop asserting a tinted label and a pill fill that the redesign removed. Alignment rails are compared across canvas glyph metrics and SVG bounding boxes, so the residual depends on the host's font; a 1px and a 0.5px tolerance both landed on the wrong side of it in CI. Two pixels still catches the whole- pixel offsets these rails encode. The compact sheet spec also read its rails while the panel was still sliding in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(app): let a browser e2e run take a private Metro cache Metro's on-disk cache is shared by every checkout on the machine. Editing one app source file rebuilds that module against everyone else's cached output and the Unistyles styles can come back desynced, which surfaces as alignment specs failing with real-looking pixel numbers that go away when the edit is reverted. E2E_METRO_CACHE_VERSION namespaces a run's cache without deleting anyone else's entries, so the cache can be ruled out before a layout failure is believed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(app): stop the chat scroll helper from hanging on a stalled sampler `scrollChatAwayFromBottom` waited on a promise that could only ever resolve from a `requestAnimationFrame` sampler started by a `wheel` listener. rAF does not fire while the page is occluded, which is reachable on a CI runner, so the sampler could stall on its first frame and the wait never returned — the test died on its own 240s timeout with nothing to read. A missed wheel event did the same. Settling the scroll before measuring is an optimization; the assertion after it is what decides the test. Sample on a timer with a deadline so a stall falls through to that assertion instead of consuming the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(app): remove layout geometry e2e coverage Rail and pixel-alignment assertions depend on host rendering details and should not define the browser E2E contract. Preserve the surrounding behavior coverage while dropping the Metro cache escape hatch that only supported diagnosing those assertions. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
267 lines
9.6 KiB
TypeScript
267 lines
9.6 KiB
TypeScript
import { test, expect, type Page } from "../support/fixtures";
|
|
import { gotoAppShell } from "../support/helpers/app";
|
|
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
|
import { getServerId } from "../support/helpers/server-id";
|
|
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
|
|
|
// The pin shortcut used to be registered by the sidebar row itself, so it silently did nothing
|
|
// whenever the row was unmounted — a collapsed project section being the common case. It now
|
|
// lives in a single always-mounted handler keyed on the active route selection.
|
|
const PIN_SHORTCUT = "ControlOrMeta+Shift+P";
|
|
|
|
function workspaceRow(page: Page, workspaceId: string) {
|
|
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
|
}
|
|
|
|
function pinnedSection(page: Page) {
|
|
return page.getByTestId("sidebar-pinned-section");
|
|
}
|
|
|
|
// Opens the workspace so it becomes the active route selection, which is what the shortcut acts on.
|
|
async function openWorkspace(page: Page, workspaceId: string) {
|
|
const row = workspaceRow(page, workspaceId);
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
await row.click();
|
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
|
}
|
|
|
|
// The project key is host-scoped and not exposed by the seed helper, so the header is addressed by
|
|
// its display name, scoped to project rows so a workspace row can never match. Pressing the header
|
|
// toggles the section, which unmounts every workspace row under it.
|
|
async function collapseProjectSection(page: Page, project: SeededWorkspace): Promise<void> {
|
|
const header = page
|
|
.locator('[data-testid^="sidebar-project-row-"]')
|
|
.filter({ hasText: project.projectDisplayName });
|
|
await expect(header).toHaveCount(1, { timeout: 30_000 });
|
|
|
|
await header.click();
|
|
await expect(workspaceRow(page, project.workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
|
}
|
|
|
|
async function switchToStatusGrouping(page: Page): Promise<void> {
|
|
await selectSidebarStatusGrouping(page);
|
|
await expect(page.getByTestId("sidebar-status-list-scroll")).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
|
|
// Status mode buckets workspaces by state rather than project, so the group holding this workspace
|
|
// is discovered from the rows container it sits in rather than assumed.
|
|
async function collapseStatusGroupContaining(page: Page, workspaceId: string): Promise<void> {
|
|
const rows = page
|
|
.locator('[data-testid^="sidebar-status-group-rows-"]')
|
|
.filter({ has: workspaceRow(page, workspaceId) });
|
|
await expect(rows).toHaveCount(1, { timeout: 30_000 });
|
|
|
|
const rowsTestId = await rows.getAttribute("data-testid");
|
|
const bucket = rowsTestId?.replace("sidebar-status-group-rows-", "");
|
|
expect(bucket).toBeTruthy();
|
|
|
|
await page.getByTestId(`sidebar-status-group-${bucket}`).click();
|
|
await expect(workspaceRow(page, workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
|
}
|
|
|
|
function readSessionMessage(
|
|
message: string | Buffer,
|
|
): { type?: unknown; requestId?: unknown } | null {
|
|
const raw = typeof message === "string" ? message : message.toString("utf8");
|
|
try {
|
|
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
|
if (envelope.type !== "session" || typeof envelope.message !== "object") {
|
|
return null;
|
|
}
|
|
return envelope.message as { type?: unknown; requestId?: unknown };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const PIN_REJECTION_MESSAGE = "Pin rejected by test.";
|
|
|
|
interface PinRpcGate {
|
|
/** Pin requests the client has sent so far. */
|
|
sentCount(): number;
|
|
}
|
|
|
|
// Proxies everything so the app boots against the real daemon, counting pin RPCs and optionally
|
|
// rejecting the first `rejectFirst` of them. The count asserts how many pins one keypress actually
|
|
// dispatched, which the rendered pin state cannot show — a toggle that fired twice lands back
|
|
// where it started.
|
|
async function installPinRpcGate(
|
|
page: Page,
|
|
options: { rejectFirst?: number } = {},
|
|
): Promise<PinRpcGate> {
|
|
const rejectFirst = options.rejectFirst ?? 0;
|
|
let sent = 0;
|
|
|
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
|
const server = ws.connectToServer();
|
|
|
|
ws.onMessage((message) => {
|
|
const sessionMessage = readSessionMessage(message);
|
|
if (
|
|
sessionMessage?.type === "workspace.pin.set.request" &&
|
|
typeof sessionMessage.requestId === "string"
|
|
) {
|
|
sent += 1;
|
|
if (sent <= rejectFirst) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "rpc_error",
|
|
payload: {
|
|
requestId: sessionMessage.requestId,
|
|
requestType: "workspace.pin.set.request",
|
|
error: PIN_REJECTION_MESSAGE,
|
|
code: "transport",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
server.send(message);
|
|
} catch {
|
|
// server socket already closed
|
|
}
|
|
});
|
|
|
|
server.onMessage((message) => {
|
|
try {
|
|
ws.send(message);
|
|
} catch {
|
|
// client socket already closed
|
|
}
|
|
});
|
|
});
|
|
|
|
return { sentCount: () => sent };
|
|
}
|
|
|
|
test.describe("Pin workspace shortcut", () => {
|
|
test("pins the active workspace while its project section is collapsed", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
await openWorkspace(page, workspace.workspaceId);
|
|
await collapseProjectSection(page, workspace);
|
|
|
|
await page.keyboard.press(PIN_SHORTCUT);
|
|
|
|
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
|
await expect(
|
|
pinnedSection(page).getByTestId(
|
|
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
|
|
),
|
|
).toBeVisible();
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("unpins the active workspace while the Pinned section is collapsed", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-unpin-" });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
await openWorkspace(page, workspace.workspaceId);
|
|
|
|
await page.keyboard.press(PIN_SHORTCUT);
|
|
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
|
|
|
await page.getByTestId("sidebar-pinned-section-header").click();
|
|
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
|
|
|
await page.keyboard.press(PIN_SHORTCUT);
|
|
|
|
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("sends exactly one pin RPC per press when the row is rendered and selected", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-expanded-" });
|
|
|
|
try {
|
|
const gate = await installPinRpcGate(page);
|
|
|
|
await gotoAppShell(page);
|
|
await openWorkspace(page, workspace.workspaceId);
|
|
|
|
await page.keyboard.press(PIN_SHORTCUT);
|
|
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
|
// Counting frames catches a press that produces zero or two RPCs — a misfiring in-flight
|
|
// guard, or a second dispatch path. It cannot detect a duplicate handler registration:
|
|
// `keyboardActionDispatcher.dispatch` returns at the first handler that returns true, so a
|
|
// shadowed second handler is unobservable from outside by design.
|
|
expect(gate.sentCount()).toBe(1);
|
|
|
|
await page.keyboard.press(PIN_SHORTCUT);
|
|
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
|
expect(gate.sentCount()).toBe(2);
|
|
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
await openWorkspace(page, workspace.workspaceId);
|
|
await switchToStatusGrouping(page);
|
|
await collapseStatusGroupContaining(page, workspace.workspaceId);
|
|
|
|
await page.keyboard.press(PIN_SHORTCUT);
|
|
|
|
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
|
await expect(
|
|
pinnedSection(page).getByTestId(
|
|
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
|
|
),
|
|
).toBeVisible();
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("shows an error toast when the host rejects the pin, and the next press succeeds", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-failure-" });
|
|
|
|
try {
|
|
const gate = await installPinRpcGate(page, { rejectFirst: 1 });
|
|
|
|
await gotoAppShell(page);
|
|
await openWorkspace(page, workspace.workspaceId);
|
|
|
|
await page.keyboard.press(PIN_SHORTCUT);
|
|
|
|
await expect(page.getByTestId("app-toast-message")).toContainText(PIN_REJECTION_MESSAGE, {
|
|
timeout: 10_000,
|
|
});
|
|
await expect(pinnedSection(page)).toHaveCount(0);
|
|
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
|
|
|
// The failure must leave the action usable: the in-flight guard has to release the key so a
|
|
// retry is not swallowed. Without that release the workspace is unpinnable for the session.
|
|
await page.keyboard.press(PIN_SHORTCUT);
|
|
|
|
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
|
expect(gate.sentCount()).toBe(2);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
});
|