mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
fix(app): tighten the Undo shortcut and prove late archive errors in a browser
The button's gap and the shortcut badge's own inset added up to 12px between the label and the keys. Late archive failures are now covered by a Playwright journey that swaps the sidebar while a real archive request is held, replacing the JSDOM component test.
This commit is contained in:
@@ -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 ({
|
||||
|
||||
@@ -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<void>;
|
||||
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<HeldArchiveRequest> {
|
||||
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<string, unknown>;
|
||||
|
||||
@@ -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) => (
|
||||
<SafeAreaProvider initialMetrics={safeAreaMetrics}>
|
||||
<ToastApiProvider api={appToast}>
|
||||
<SidebarToastRegion active={isActive}>
|
||||
<CaptureToast />
|
||||
</SidebarToastRegion>
|
||||
</ToastApiProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 ? <Shortcut chord={undoKeys} /> : null), [undoKeys]);
|
||||
const undoShortcut = useMemo(
|
||||
() => (undoKeys ? <Shortcut chord={undoKeys} style={styles.shortcut} /> : null),
|
||||
[undoKeys],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user