Files
paseo/packages/app/e2e/support/helpers/workspace-archive-undo.ts
T
Mohamed Boudra e4b6acb36d fix(app): show the archive undo toast in the sidebar, with only Undo pressable (#9104)
* fix(app): show the archive undo toast in the sidebar, with only Undo pressable

The archive button sat 2px from the kebab, so a press aimed at the menu could
archive the workspace. The toast card was a Pressable used only to pause on
hover, which made the whole card look clickable.

Toasts raised by the workspace list now land above the sidebar footer, next to
the row that raised them. A collapsed sidebar stays mounted and its rows still
handle the archive shortcut, so while it is hidden those toasts go to the app
host. A toast leaves when the sidebar hides because its dismissal is what ends
an archive's undo window.

* fix(app): show late archive failures after the sidebar unmounts

An archive keeps the toast API it started with. Unmounting the sidebar (focus
mode, a layout change, a plugin view) left the ref saying it was visible, so a
failure arriving afterwards went to the removed host and the user saw nothing.

* 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.

* test(app): prove late archive errors against a real daemon refusal

The test answered the held archive request with a response it built itself, so no daemon ever failed. It now releases the held request naming a workspace the daemon has no record of, which is the one archive refusal a test can trigger without patching the daemon.
2026-11-09 09:02:55 +07:00

165 lines
6.5 KiB
TypeScript

import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
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";
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const UNDO_SHORTCUT = process.platform === "darwin" ? "Meta+Z" : "Control+Z";
const ARCHIVE_SHORTCUT =
process.platform === "darwin" ? "Meta+Shift+Backspace" : "Control+Shift+Backspace";
/** The archived toast's lifetime, from `WORKSPACE_ARCHIVE_UNDO_WINDOW_MS`. */
const UNDO_WINDOW_MS = 6000;
export function workspaceRow(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
}
export function rowArchiveButton(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-archive-button-${getServerId()}:${workspaceId}`);
}
export function rowKebab(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`);
}
export function undoButton(page: Page) {
return page.getByRole("button", { name: /^Undo/ });
}
export async function workspaceTitleWidth(page: Page, workspaceId: string): Promise<number> {
const box = await page
.getByTestId(`sidebar-workspace-title-${getServerId()}:${workspaceId}`)
.boundingBox();
if (!box) throw new Error(`Title of ${workspaceId} is not laid out`);
return box.width;
}
/** Archives a workspace with its row's hover button and waits for the undo toast. */
export async function archiveFromRowButton(page: Page, workspaceId: string): Promise<void> {
page.once("dialog", (dialog) => void dialog.accept());
await workspaceRow(page, workspaceId).hover();
await rowArchiveButton(page, workspaceId).click();
await expectWorkspaceAbsentFromSidebar(page, workspaceId);
await expect(undoButton(page)).toBeVisible();
}
/** Drags the sidebar's edge past its minimum, leaving it as narrow as it goes. */
export async function resizeSidebarToMinimum(page: Page): Promise<void> {
const handle = await page.getByTestId("left-sidebar-resize-handle").boundingBox();
if (!handle) throw new Error("Sidebar resize handle is not laid out");
const y = handle.y + handle.height / 2;
await page.mouse.move(handle.x + handle.width / 2, y);
await page.mouse.down();
await page.mouse.move(handle.x - 30, y, { steps: 8 });
await page.mouse.move(10, y, { steps: 16 });
await page.mouse.up();
await expect
.poll(async () => (await page.getByTestId("sidebar-footer").boundingBox())?.width)
.toBeLessThanOrEqual(200);
}
export async function pressArchiveShortcut(page: Page): Promise<void> {
await page.keyboard.press(ARCHIVE_SHORTCUT);
}
export async function pressUndoShortcut(page: Page): Promise<void> {
await page.keyboard.press(UNDO_SHORTCUT);
}
/** Moves the page clock past the moment an unheld archived toast would have closed. */
export async function outlastUndoWindow(page: Page): Promise<void> {
await page.clock.fastForward(UNDO_WINDOW_MS + 1000);
}
/** Waits until the host lists the workspace as active with no archive running on it. */
export async function expectHostWorkspaceSettledActive(
client: DaemonClient,
workspaceId: string,
): Promise<void> {
await expect
.poll(
async () => {
const { entries } = await client.fetchWorkspaces();
const entry = entries.find((candidate) => candidate.id === workspaceId);
return entry ? { active: true, archiving: Boolean(entry.archivingAt) } : null;
},
{ timeout: 30_000 },
)
.toEqual({ active: true, archiving: false });
}
export async function expectDirectoryPresence(directory: string, present: boolean): Promise<void> {
await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present);
}
export interface HeldArchiveRequest {
waitForHeld(): Promise<void>;
/** Sends the held request to the daemon naming a workspace it has no record of, so it refuses. */
releaseForUnknownWorkspace(workspaceId: string): void;
}
/** Keeps the next archive request from reaching the daemon until the test releases it. */
export async function holdNextArchiveRequest(page: Page): Promise<HeldArchiveRequest> {
let held: { request: SessionRequest; forward: (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 = { request, forward: (raw) => server.send(raw) };
return;
}
server.send(message);
});
server.onMessage((message) => browser.send(message));
});
return {
waitForHeld: () => expect.poll(() => held !== null).toBe(true),
releaseForUnknownWorkspace: (workspaceId) => {
if (!held) throw new Error("No archive request is held");
held.forward(JSON.stringify({ type: "session", message: { ...held.request, workspaceId } }));
},
};
}
interface SessionRequest {
type: string;
requestId: string;
workspaceId: string;
}
function parseSessionRequest(message: string | Buffer): SessionRequest | 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>;
expectStarted(): Promise<void>;
release(): void;
}
/** A worktree teardown that parks an archive mid-flight, so an undo lands while it runs. */
export function createGatedTeardown(): GatedTeardown {
const gateDir = mkdtempSync(path.join(tmpdir(), "archive-undo-gate-"));
const startedPath = path.join(gateDir, "started");
const releasePath = path.join(gateDir, "release");
const teardown = `node -e "const fs=require('fs');fs.writeFileSync('${startedPath}','started');const timer=setInterval(()=>{if(fs.existsSync('${releasePath}'))clearInterval(timer)},20)"`;
return {
paseoConfig: { worktree: { teardown: [teardown] } },
expectStarted: () => expectDirectoryPresence(startedPath, true),
release: () => writeFileSync(releasePath, "release\n"),
};
}