Files
paseo/packages/app/src/stores/session-store.test.ts
T
Mohamed Boudra 5da6548aff Improve the archived workspace restore flow (#2002)
* fix(app): restore missing workspaces from History

Workspace and agent archival are separate lifecycles. Carry restore intent from History so closed agents can reopen archived workspaces without changing ordinary navigation.

* fix(app): wait for workspace hydration before restore

* fix(app): preserve History agent unarchive

* fix(app): reopen archived History agents outside active directory

History entries are not merged into the active session maps. Carry the row archive state through explicit restore intent so live workspaces still reopen without broadening ordinary navigation.

* fix(app): ignore stale History archive state for active agents

A cached History row can outlive a successful reopen. When the workspace exists, live session state now wins so an active agent is not interrupted by another refresh.

* fix(app): reopen every selected archived History agent

History entries without workspace IDs returned before restoration, and a second archived agent sharing an in-flight workspace restore was dropped. Preserve both agent-only and deferred reopen intent.

* fix(app): require explicit workspace recovery

* fix: preserve workspace recovery contracts

* fix: preserve workspace recovery compatibility

* fix: preserve recovered workspace session state

* fix: preserve terminal navigation timing

* fix(server): preserve successful workspace recovery

* fix: preserve workspace recovery intent
2026-07-16 17:22:37 +02:00

474 lines
14 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
normalizeWorkspaceDescriptor,
useSessionStore,
type WorkspaceDescriptor,
} from "./session-store";
import { patchWorkspaceScripts } from "../contexts/session-workspace-scripts";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
projectRootPath: input.projectRootPath ?? "/repo",
workspaceDirectory: input.workspaceDirectory ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
}
afterEach(() => {
useSessionStore.getState().clearSession("test-server");
});
function initializeTestSession(): void {
useSessionStore.getState().initializeSession("test-server", null as unknown as DaemonClient);
}
function getTestSessionReferences() {
const state = useSessionStore.getState();
const session = state.sessions["test-server"];
if (!session) {
throw new Error("test session is not initialized");
}
return {
sessions: state.sessions,
session,
workspaces: session.workspaces,
emptyProjects: session.emptyProjects,
};
}
describe("normalizeWorkspaceDescriptor", () => {
it("normalizes workspace scripts and invalid activity timestamps", () => {
const scripts = [
{
scriptName: "web",
type: "service" as const,
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running" as const,
health: "healthy" as const,
exitCode: null,
terminalId: null,
},
];
const workspace = normalizeWorkspaceDescriptor({
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "running",
statusEnteredAt: null,
activityAt: "not-a-date",
diffStat: null,
scripts,
});
expect(workspace.scripts).toEqual([
{
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
terminalId: null,
},
]);
expect(workspace.scripts).not.toBe(scripts);
});
it("canonicalizes the workspace directory and treats a blank one as empty", () => {
const canonical = normalizeWorkspaceDescriptor({
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo/app/",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
});
expect(canonical.workspaceDirectory).toBe("/repo/app");
const blank = normalizeWorkspaceDescriptor({
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: " ",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
});
expect(blank.workspaceDirectory).toBe("");
});
it("defaults missing scripts to an empty array", () => {
const payload = {
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
} as WorkspaceDescriptorPayload;
const workspace = normalizeWorkspaceDescriptor(payload);
expect(workspace.scripts).toEqual([]);
});
it("defaults missing archivingAt to null", () => {
const payload = {
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
} as unknown as WorkspaceDescriptorPayload;
const workspace = normalizeWorkspaceDescriptor(payload);
expect(workspace.archivingAt).toBeNull();
});
it("normalizes statusEnteredAt strings to Date and missing or null values to null", () => {
const basePayload = {
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
status: "running",
activityAt: null,
diffStat: null,
scripts: [],
} satisfies Omit<WorkspaceDescriptorPayload, "statusEnteredAt" | "archivingAt">;
const withString = normalizeWorkspaceDescriptor({
...basePayload,
archivingAt: null,
statusEnteredAt: "2026-05-12T09:30:00.000Z",
});
const withNull = normalizeWorkspaceDescriptor({
...basePayload,
archivingAt: null,
statusEnteredAt: null,
});
const missing = normalizeWorkspaceDescriptor({
...basePayload,
archivingAt: null,
} as unknown as WorkspaceDescriptorPayload);
expect(withString.statusEnteredAt).toEqual(new Date("2026-05-12T09:30:00.000Z"));
expect(withNull.statusEnteredAt).toBeNull();
expect(missing.statusEnteredAt).toBeNull();
});
it("preserves project placement from workspace descriptor payloads", () => {
const workspace = normalizeWorkspaceDescriptor({
id: "1",
projectId: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectRootPath: "/repo/app",
workspaceDirectory: "/repo/app",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
project: {
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/app.git",
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
});
expect(workspace.project).toEqual({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/app.git",
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
});
});
describe("mergeWorkspaces", () => {
it("preserves scripts on merged workspace entries", () => {
const store = useSessionStore.getState();
store.initializeSession("test-server", null as unknown as DaemonClient);
store.setWorkspaces(
"test-server",
new Map([["/repo/main", createWorkspace({ id: "/repo/main", scripts: [] })]]),
);
store.mergeWorkspaces("test-server", [
createWorkspace({
id: "/repo/main",
scripts: [
{
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
terminalId: null,
},
],
}),
]);
expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.scripts).toEqual([
{
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
terminalId: null,
},
]);
});
it("preserves identity when merging content-equal workspace descriptors", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspace = createWorkspace({ id: "/repo/main" });
store.mergeWorkspaces("test-server", [workspace]);
const first = getTestSessionReferences();
store.mergeWorkspaces("test-server", [{ ...workspace, scripts: [...workspace.scripts] }]);
const second = getTestSessionReferences();
expect(second.sessions).toBe(first.sessions);
expect(second.session).toBe(first.session);
expect(second.workspaces).toBe(first.workspaces);
expect(second.workspaces.get("/repo/main")).toBe(first.workspaces.get("/repo/main"));
});
it("preserves unaffected workspace entry identity when one workspace changes", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspaceA = createWorkspace({ id: "/repo/a", name: "main" });
const workspaceB = createWorkspace({ id: "/repo/b", name: "feature" });
store.mergeWorkspaces("test-server", [workspaceA, workspaceB]);
const before = getTestSessionReferences();
const beforeA = before.workspaces.get("/repo/a");
const beforeB = before.workspaces.get("/repo/b");
store.mergeWorkspaces("test-server", [{ ...workspaceA, status: "running" }]);
const after = getTestSessionReferences();
expect(after.sessions).not.toBe(before.sessions);
expect(after.session).not.toBe(before.session);
expect(after.workspaces).not.toBe(before.workspaces);
expect(after.workspaces.get("/repo/a")).not.toBe(beforeA);
expect(after.workspaces.get("/repo/b")).toBe(beforeB);
});
it("uses incoming null diff stat as authoritative", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspace = createWorkspace({
id: "/repo/main",
diffStat: { additions: 2, deletions: 1 },
});
store.mergeWorkspaces("test-server", [workspace]);
const before = getTestSessionReferences();
store.mergeWorkspaces("test-server", [{ ...workspace, diffStat: null }]);
const after = getTestSessionReferences();
expect(after.sessions).not.toBe(before.sessions);
expect(after.session).not.toBe(before.session);
expect(after.workspaces).not.toBe(before.workspaces);
expect(after.workspaces.get(workspace.id)?.diffStat).toBeNull();
});
});
describe("setWorkspaces", () => {
it("preserves identity when replacing workspaces with content-equal entries", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspace = createWorkspace({ id: "/repo/main" });
store.setWorkspaces("test-server", new Map([[workspace.id, workspace]]));
const before = getTestSessionReferences();
store.setWorkspaces(
"test-server",
new Map([[workspace.id, { ...workspace, scripts: [...workspace.scripts] }]]),
);
const after = getTestSessionReferences();
expect(after.sessions).toBe(before.sessions);
expect(after.session).toBe(before.session);
expect(after.workspaces).toBe(before.workspaces);
expect(after.workspaces.get(workspace.id)).toBe(before.workspaces.get(workspace.id));
});
});
describe("removeWorkspace", () => {
it("preserves identity when removing a missing workspace", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspace = createWorkspace({ id: "/repo/main" });
store.setWorkspaces("test-server", new Map([[workspace.id, workspace]]));
const before = getTestSessionReferences();
store.removeWorkspace("test-server", "/repo/missing");
const after = getTestSessionReferences();
expect(after.sessions).toBe(before.sessions);
expect(after.session).toBe(before.session);
expect(after.workspaces).toBe(before.workspaces);
});
});
describe("removeEmptyProject", () => {
it("removes an empty project by project id", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setEmptyProjects("test-server", [
{
projectId: "project-empty",
projectDisplayName: "Empty",
projectCustomName: null,
projectRootPath: "/repo/empty",
projectKind: "git",
},
]);
store.removeEmptyProject("test-server", "project-empty");
expect(getTestSessionReferences().emptyProjects.has("project-empty")).toBe(false);
});
it("preserves identity when removing a missing empty project", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setEmptyProjects("test-server", [
{
projectId: "project-empty",
projectDisplayName: "Empty",
projectCustomName: null,
projectRootPath: "/repo/empty",
projectKind: "git",
},
]);
const before = getTestSessionReferences();
store.removeEmptyProject("test-server", "project-missing");
const after = getTestSessionReferences();
expect(after.sessions).toBe(before.sessions);
expect(after.session).toBe(before.session);
expect(after.emptyProjects).toBe(before.emptyProjects);
});
});
describe("patchWorkspaceScripts", () => {
it("preserves workspace entry identity when scripts are content-equal", () => {
const script = {
scriptName: "web",
type: "service" as const,
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running" as const,
health: "healthy" as const,
exitCode: null,
terminalId: null,
};
const workspace = createWorkspace({ id: "/repo/main", scripts: [script] });
const current = new Map([[workspace.id, workspace]]);
const next = patchWorkspaceScripts(current, {
workspaceId: workspace.id,
scripts: [{ ...script }],
});
expect(next).toBe(current);
expect(next.get(workspace.id)).toBe(workspace);
});
});