Files
paseo/packages/app/src/workspace/workspace-archive.test.ts
T
Mohamed BoudraandGitHub 03dcda7c41 Archive any workspace from the app (#2003)
* feat(app): expose archive for every workspace

Workspace actions now archive one workspace by id. The daemon remains responsible for backing-specific cleanup, including removing a managed worktree only after its final workspace is archived.

* refactor(app): clarify archive descriptor name

* fix(app): preserve workspace archive safety

* fix(app): allow archive without upstream

* refactor(app): remove stale archive callback

* test(app): cover workspace archive ownership
2026-07-11 18:22:31 +02:00

221 lines
6.9 KiB
TypeScript

import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearWorkspaceArchivePending,
isWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically,
type WorkspaceArchiveTarget,
} from "@/workspace/workspace-archive";
const SERVER_ID = "workspace-archive-test";
const SECOND_SERVER_ID = "workspace-archive-test-2";
type ArchiveWorkspacePayload = Awaited<ReturnType<DaemonClient["archiveWorkspace"]>>;
function archivePayload(input: {
workspaceId: string;
error?: string | null;
}): ArchiveWorkspacePayload {
return {
requestId: "request",
workspaceId: input.workspaceId,
archivedAt: null,
error: input.error ?? null,
};
}
function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
return {
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Project",
projectRootPath: "/repo/project",
workspaceDirectory: "/repo/project/workspace-1",
projectKind: "git",
workspaceKind: "worktree",
name: "workspace-1",
status: "done",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: [],
...input,
};
}
function target(input?: Partial<WorkspaceArchiveTarget>): WorkspaceArchiveTarget {
const base = workspace();
return {
serverId: SERVER_ID,
workspaceId: base.id,
...input,
};
}
function createClient(
archiveWorkspace: DaemonClient["archiveWorkspace"],
): Pick<DaemonClient, "archiveWorkspace"> {
return { archiveWorkspace };
}
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve: (value: T) => void = () => {};
let reject: (error: unknown) => void = () => {};
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function storedWorkspaceOn(serverId: string, id: string): WorkspaceDescriptor | undefined {
return useSessionStore.getState().sessions[serverId]?.workspaces.get(id);
}
function storedWorkspace(id: string): WorkspaceDescriptor | undefined {
return storedWorkspaceOn(SERVER_ID, id);
}
beforeEach(() => {
useSessionStore.getState().initializeSession(SERVER_ID, {} as DaemonClient);
});
afterEach(() => {
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-1" });
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-2" });
clearWorkspaceArchivePending({ serverId: SECOND_SERVER_ID, workspaceId: "workspace-1" });
clearWorkspaceArchivePending({ serverId: SECOND_SERVER_ID, workspaceId: "workspace-2" });
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
describe("archiveWorkspaceOptimistically", () => {
it("hides the workspace and marks the archive pending while the daemon call runs", async () => {
const archived = workspace();
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
const releaseArchive = deferred<ArchiveWorkspacePayload>();
const client = createClient(vi.fn(async () => releaseArchive.promise));
const archive = archiveWorkspaceOptimistically({
client,
workspace: target(),
});
expect(storedWorkspace(archived.id)).toBeUndefined();
expect(
isWorkspaceArchivePending({
serverId: SERVER_ID,
workspaceId: archived.id,
}),
).toBe(true);
releaseArchive.resolve(archivePayload({ workspaceId: archived.id }));
await archive;
expect(storedWorkspace(archived.id)).toBeUndefined();
});
it("restores the workspace and clears pending state when the daemon rejects the archive", async () => {
const archived = workspace();
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
const client = createClient(
vi.fn(async () => archivePayload({ workspaceId: archived.id, error: "nope" })),
);
await expect(
archiveWorkspaceOptimistically({
client,
workspace: target(),
}),
).rejects.toThrow("nope");
expect(storedWorkspace(archived.id)).toEqual(archived);
expect(
isWorkspaceArchivePending({
serverId: SERVER_ID,
workspaceId: archived.id,
}),
).toBe(false);
});
});
describe("archiveWorkspacesOptimistically", () => {
it("returns failures and restores only the workspaces whose archive failed", async () => {
const first = workspace({ id: "workspace-1" });
const second = workspace({
id: "workspace-2",
workspaceDirectory: "/repo/project/workspace-2",
name: "workspace-2",
});
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [first, second]);
const client = createClient(
vi.fn(async (workspaceId) =>
archivePayload({
workspaceId,
error: workspaceId === second.id ? "failed" : null,
}),
),
);
const failures = await archiveWorkspacesOptimistically({
getClient: () => client,
workspaces: [target({ workspaceId: first.id }), target({ workspaceId: second.id })],
});
expect(failures).toHaveLength(1);
expect(failures[0]?.workspaceId).toBe(second.id);
expect(storedWorkspace(first.id)).toBeUndefined();
expect(storedWorkspace(second.id)).toEqual(second);
});
it("archives each workspace through its own server client", async () => {
const first = workspace({ id: "workspace-1" });
const second = workspace({
id: "workspace-2",
workspaceDirectory: "/repo/project/workspace-2",
name: "workspace-2",
});
useSessionStore.getState().initializeSession(SECOND_SERVER_ID, {} as DaemonClient);
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [first]);
useSessionStore.getState().mergeWorkspaces(SECOND_SERVER_ID, [second]);
const archivedByServer = new Map<string, string[]>();
const clientFor = (serverId: string) =>
createClient(async (workspaceId) => {
archivedByServer.set(serverId, [...(archivedByServer.get(serverId) ?? []), workspaceId]);
return archivePayload({ workspaceId });
});
const failures = await archiveWorkspacesOptimistically({
getClient: (serverId) => clientFor(serverId),
workspaces: [
target({
serverId: SERVER_ID,
workspaceId: first.id,
}),
target({
serverId: SECOND_SERVER_ID,
workspaceId: second.id,
}),
],
});
expect(failures).toEqual([]);
expect(archivedByServer).toEqual(
new Map([
[SERVER_ID, [first.id]],
[SECOND_SERVER_ID, [second.id]],
]),
);
expect(storedWorkspaceOn(SERVER_ID, first.id)).toBeUndefined();
expect(storedWorkspaceOn(SECOND_SERVER_ID, second.id)).toBeUndefined();
});
});