diff --git a/packages/app/e2e/browser/workspace-archive-undo.spec.ts b/packages/app/e2e/browser/workspace-archive-undo.spec.ts index 6606a8a3c5..c6c6fcad56 100644 --- a/packages/app/e2e/browser/workspace-archive-undo.spec.ts +++ b/packages/app/e2e/browser/workspace-archive-undo.spec.ts @@ -18,6 +18,7 @@ import { createGatedTeardown, expectDirectoryPresence, expectHostWorkspaceSettledActive, + holdNextArchiveRequest, outlastUndoWindow, pressArchiveShortcut, pressUndoShortcut, @@ -109,6 +110,30 @@ test.describe("Workspace archive undo", () => { await expect(page.getByTestId("app-toast-message")).toHaveCSS("cursor", "auto"); await expect(undoButton(page)).toHaveCSS("cursor", "pointer"); + + const [labelBox, shortcutBox] = await Promise.all([ + undoButton(page).getByText("Undo", { exact: true }).boundingBox(), + undoButton(page).getByText(/Z$/).boundingBox(), + ]); + if (!labelBox || !shortcutBox) throw new Error("Undo button is not laid out"); + expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5); + }); + + test("an archive that fails after the sidebar was replaced still shows its error", async ({ + page, + }) => { + const archive = await holdNextArchiveRequest(page); + const worktree = await seedWorktree(page, "archive-late-failure"); + + await archiveFromRowButton(page, worktree.workspaceId); + await archive.waitForHeld(); + // Crossing the compact breakpoint swaps the sidebar, unmounting the toast's host. + await page.setViewportSize({ width: 400, height: 720 }); + await expect(undoButton(page)).toHaveCount(0); + + archive.fail("Injected archive failure."); + + await expect(page.getByTestId("app-toast-message")).toHaveText("Injected archive failure."); }); test("the archived toast keeps Undo inside the card at the narrowest sidebar", async ({ diff --git a/packages/app/e2e/support/helpers/workspace-archive-undo.ts b/packages/app/e2e/support/helpers/workspace-archive-undo.ts index 54516b9d41..ae4a802edd 100644 --- a/packages/app/e2e/support/helpers/workspace-archive-undo.ts +++ b/packages/app/e2e/support/helpers/workspace-archive-undo.ts @@ -4,6 +4,7 @@ import path from "node:path"; import type { Page } from "@playwright/test"; import { expect } from "../fixtures"; import type { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { daemonWsRoutePattern } from "./daemon-port"; import { getServerId } from "./server-id"; import { expectWorkspaceAbsentFromSidebar } from "./sidebar"; @@ -97,6 +98,60 @@ export async function expectDirectoryPresence(directory: string, present: boolea await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present); } +export interface HeldArchiveRequest { + waitForHeld(): Promise; + fail(error: string): void; +} + +/** Keeps the next archive request from the host and lets the test answer it with a failure. */ +export async function holdNextArchiveRequest(page: Page): Promise { + let held: { requestId: string; workspaceId: string; respond: (raw: string) => void } | null = + null; + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((message) => { + const request = parseSessionRequest(message); + if (!held && request?.type === "archive_workspace_request") { + held = { + requestId: request.requestId, + workspaceId: request.workspaceId, + respond: (raw) => browser.send(raw), + }; + return; + } + server.send(message); + }); + server.onMessage((message) => browser.send(message)); + }); + return { + waitForHeld: () => expect.poll(() => held !== null).toBe(true), + fail: (error) => { + if (!held) throw new Error("No archive request is held"); + const { requestId, workspaceId, respond } = held; + respond( + JSON.stringify({ + type: "session", + message: { + type: "archive_workspace_response", + payload: { requestId, workspaceId, archivedAt: null, error }, + }, + }), + ); + }, + }; +} + +function parseSessionRequest( + message: string | Buffer, +): { type: string; requestId: string; workspaceId: string } | null { + try { + const envelope = JSON.parse(typeof message === "string" ? message : message.toString("utf8")); + return envelope?.type === "session" ? envelope.message : null; + } catch { + return null; + } +} + export interface GatedTeardown { /** The `paseo.json` config whose worktree teardown parks until released. */ paseoConfig: Record; diff --git a/packages/app/src/components/sidebar/sidebar-toast-region.test.tsx b/packages/app/src/components/sidebar/sidebar-toast-region.test.tsx deleted file mode 100644 index 319e72226b..0000000000 --- a/packages/app/src/components/sidebar/sidebar-toast-region.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -// @vitest-environment jsdom - -import { render } from "@testing-library/react"; -import { SafeAreaProvider } from "react-native-safe-area-context"; -import { describe, expect, it, vi } from "vitest"; -import type { ToastApi } from "@/components/toast-host"; -import { ToastApiProvider, useToast } from "@/contexts/toast-context"; -import { SidebarToastRegion } from "./sidebar-toast-region"; - -const safeAreaMetrics = { - frame: { x: 0, y: 0, width: 800, height: 600 }, - insets: { top: 0, left: 0, right: 0, bottom: 0 }, -}; - -function createFakeToast(): ToastApi { - return { show: vi.fn(() => () => {}), copied: vi.fn(), error: vi.fn() }; -} - -function renderRegion(active: boolean) { - const appToast = createFakeToast(); - let regionToast: ToastApi | null = null; - function CaptureToast() { - regionToast = useToast(); - return null; - } - const tree = (isActive: boolean) => ( - - - - - - - - ); - const view = render(tree(active)); - return { - appToast, - toast: () => regionToast as unknown as ToastApi, - setActive: (next: boolean) => view.rerender(tree(next)), - unmount: view.unmount, - }; -} - -describe("SidebarToastRegion", () => { - it("keeps toasts in the sidebar while it is visible", () => { - const { appToast, toast } = renderRegion(true); - - toast().error("Archive failed"); - - expect(appToast.error).not.toHaveBeenCalled(); - }); - - it("sends toasts to the app while the sidebar is hidden", () => { - const { appToast, toast, setActive } = renderRegion(true); - - setActive(false); - toast().error("Archive failed"); - - expect(appToast.error).toHaveBeenCalledWith("Archive failed"); - }); - - it("sends a late failure to the app after the sidebar unmounts", () => { - const { appToast, toast, unmount } = renderRegion(true); - const savedToast = toast(); - - unmount(); - savedToast.error("Archive failed"); - - expect(appToast.error).toHaveBeenCalledWith("Archive failed"); - }); -}); diff --git a/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx b/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx index de416b906f..4cca52fd48 100644 --- a/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx +++ b/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx @@ -9,7 +9,10 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; export function WorkspaceArchivedToast({ onUndo }: { onUndo: () => void }) { const { t } = useTranslation(); const undoKeys = useShortcutKeys("undo-archive-workspace"); - const undoShortcut = useMemo(() => (undoKeys ? : null), [undoKeys]); + const undoShortcut = useMemo( + () => (undoKeys ? : null), + [undoKeys], + ); return ( @@ -33,6 +36,10 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, + // Cancels the badge's own inset, which would otherwise add to the button's gap. + shortcut: { + marginLeft: -theme.spacing[1], + }, message: { flexShrink: 1, color: theme.colors.foreground,