mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
* 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.
165 lines
6.5 KiB
TypeScript
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"),
|
|
};
|
|
}
|