mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
Order ordinary sidebar workspaces by Created or Last prompt, preserve project and pin ordering, and keep later active or unacknowledged work visible beyond the first seven rows. Preserve reveal state through live updates and verify sorting, visibility, compatibility, and rendering behavior.
805 lines
25 KiB
TypeScript
805 lines
25 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,
|
|
selectAgentTurnPresentation,
|
|
selectAgentTimelineState,
|
|
useSessionStore,
|
|
type Agent,
|
|
type WorkspaceDescriptor,
|
|
} from "./session-store";
|
|
import type { StreamItem } from "../types/stream";
|
|
import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness";
|
|
|
|
function createTestAgent(agentId: string): Agent {
|
|
return {
|
|
serverId: "test-server",
|
|
id: agentId,
|
|
provider: "codex",
|
|
status: "idle",
|
|
turn: { phase: "idle", cancellationRequestId: null },
|
|
createdAt: new Date(0),
|
|
updatedAt: new Date(0),
|
|
lastUserMessageAt: null,
|
|
lastActivityAt: new Date(0),
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: false,
|
|
supportsMcpServers: false,
|
|
supportsReasoningStream: false,
|
|
supportsToolInvocations: false,
|
|
},
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: null,
|
|
title: null,
|
|
cwd: "/repo",
|
|
model: null,
|
|
parentAgentId: null,
|
|
labels: {},
|
|
};
|
|
}
|
|
|
|
function applyTestTurn(
|
|
serverId: string,
|
|
agentId: string,
|
|
transition: TurnLivenessTransition | readonly TurnLivenessTransition[],
|
|
): void {
|
|
useSessionStore.getState().setAgents(serverId, (agents) => {
|
|
const agent = agents.get(agentId) ?? createTestAgent(agentId);
|
|
const transitions = Array.isArray(transition) ? transition : [transition];
|
|
const turn = transitions.reduce(reduceTurnLiveness, agent.turn);
|
|
const next = new Map(agents);
|
|
next.set(agentId, { ...agent, status: turn.phase === "open" ? "running" : "idle", turn });
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function createWorkspace(
|
|
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
|
): WorkspaceDescriptor {
|
|
return {
|
|
createdAt: null,
|
|
lastUserMessageAt: null,
|
|
id: input.id,
|
|
projectId: input.projectId ?? "project-1",
|
|
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
|
projectCustomName: input.projectCustomName ?? null,
|
|
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,
|
|
};
|
|
}
|
|
|
|
function submittedMessage(clientMessageId: string): Extract<StreamItem, { kind: "user_message" }> {
|
|
return {
|
|
kind: "user_message",
|
|
id: clientMessageId,
|
|
clientMessageId,
|
|
text: clientMessageId,
|
|
timestamp: new Date("2026-07-31T10:00:00.000Z"),
|
|
};
|
|
}
|
|
|
|
function hasSubmittedMessage(items: readonly StreamItem[] | undefined, clientMessageId: string) {
|
|
return items?.some(
|
|
(item) => item.kind === "user_message" && item.clientMessageId === clientMessageId,
|
|
);
|
|
}
|
|
|
|
function permutations<T>(values: readonly T[]): T[][] {
|
|
if (values.length === 0) return [[]];
|
|
return values.flatMap((value, index) =>
|
|
permutations([...values.slice(0, index), ...values.slice(index + 1)]).map((suffix) =>
|
|
[value].concat(suffix),
|
|
),
|
|
);
|
|
}
|
|
|
|
function taskTexts(tasks: ReadonlyArray<{ text: string }>): string[] {
|
|
return tasks.map((task) => task.text);
|
|
}
|
|
|
|
describe("agent task state", () => {
|
|
it("notifies the task subscriber only when its task snapshot changes", () => {
|
|
initializeTestSession();
|
|
const snapshots: string[][] = [];
|
|
const unsubscribe = useSessionStore.subscribe(
|
|
(state) => state.sessions["test-server"]?.agentTasks.get("agent-1") ?? [],
|
|
(tasks) => snapshots.push(taskTexts(tasks)),
|
|
);
|
|
|
|
const tasks = [{ id: "1", text: "Inspect", status: "pending" as const, completed: false }];
|
|
useSessionStore
|
|
.getState()
|
|
.setAgentStreamState("test-server", "agent-1", { taskSnapshot: tasks });
|
|
useSessionStore.getState().setIsPlayingAudio("test-server", true);
|
|
useSessionStore
|
|
.getState()
|
|
.setAgentStreamState("test-server", "agent-1", { taskSnapshot: [...tasks] });
|
|
useSessionStore.getState().setAgentStreamState("test-server", "agent-1", {
|
|
taskSnapshot: [{ ...tasks[0], status: "completed", completed: true }],
|
|
});
|
|
unsubscribe();
|
|
|
|
expect(snapshots).toEqual([["Inspect"], ["Inspect"]]);
|
|
});
|
|
|
|
it("restores the latest task snapshot from an authoritative timeline", () => {
|
|
initializeTestSession();
|
|
const todo: StreamItem = {
|
|
kind: "todo_list",
|
|
id: "todo-1",
|
|
provider: "codex",
|
|
timestamp: new Date("2026-08-11T10:00:00.000Z"),
|
|
activity: { type: "started", task: "Verify" },
|
|
items: [{ text: "Verify", status: "in_progress", completed: false }],
|
|
};
|
|
|
|
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
|
|
items: [todo],
|
|
head: [],
|
|
range: null,
|
|
older: "none",
|
|
newer: false,
|
|
synchronized: true,
|
|
acknowledgedClientMessageIds: [],
|
|
});
|
|
|
|
expect(useSessionStore.getState().sessions["test-server"]?.agentTasks.get("agent-1")).toEqual(
|
|
todo.items,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("agent timeline state", () => {
|
|
it("commits canonical items, range, and older availability as one synced state", () => {
|
|
initializeTestSession();
|
|
const items: StreamItem[] = [
|
|
{
|
|
kind: "assistant_message",
|
|
id: "canonical-row",
|
|
text: "canonical",
|
|
timestamp: new Date("2026-07-27T10:00:00.000Z"),
|
|
},
|
|
];
|
|
|
|
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
|
|
items,
|
|
head: [],
|
|
range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 },
|
|
older: "available",
|
|
newer: false,
|
|
synchronized: true,
|
|
acknowledgedClientMessageIds: [],
|
|
});
|
|
|
|
expect(
|
|
selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"),
|
|
).toEqual({
|
|
status: "synced",
|
|
items,
|
|
range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 },
|
|
older: "available",
|
|
newer: "none",
|
|
});
|
|
});
|
|
|
|
it("represents an empty authoritative timeline without inventing a range", () => {
|
|
initializeTestSession();
|
|
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
|
|
items: [],
|
|
head: [],
|
|
range: null,
|
|
older: "none",
|
|
newer: false,
|
|
synchronized: true,
|
|
acknowledgedClientMessageIds: [],
|
|
});
|
|
|
|
expect(
|
|
selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"),
|
|
).toEqual({ status: "synced", items: [], range: null, older: "none", newer: "none" });
|
|
});
|
|
|
|
it("preserves older availability when an applied response leaves it unchanged", () => {
|
|
initializeTestSession();
|
|
const store = useSessionStore.getState();
|
|
store.applyAgentTimelineResponseState("test-server", "agent-1", {
|
|
items: [],
|
|
head: [],
|
|
range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 },
|
|
older: "available",
|
|
newer: false,
|
|
synchronized: true,
|
|
acknowledgedClientMessageIds: [],
|
|
});
|
|
|
|
store.applyAgentTimelineResponseState("test-server", "agent-1", {
|
|
items: [],
|
|
head: [],
|
|
range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 },
|
|
older: "unchanged",
|
|
newer: false,
|
|
synchronized: false,
|
|
acknowledgedClientMessageIds: [],
|
|
});
|
|
|
|
expect(
|
|
selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"),
|
|
).toEqual({
|
|
status: "synced",
|
|
items: [],
|
|
range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 },
|
|
older: "available",
|
|
newer: "none",
|
|
});
|
|
});
|
|
|
|
it("stores turn liveness transitions without duplicating their policy", () => {
|
|
initializeTestSession();
|
|
const store = useSessionStore.getState();
|
|
applyTestTurn("test-server", "agent-1", {
|
|
type: "snapshot",
|
|
activeTurn: { turnId: "turn-1", startedAt: null },
|
|
});
|
|
expect(store.getSession("test-server")?.agents.get("agent-1")?.turn).toEqual({
|
|
phase: "open",
|
|
turnId: "turn-1",
|
|
startedAt: null,
|
|
cancellationRequestId: null,
|
|
});
|
|
|
|
applyTestTurn("test-server", "agent-1", {
|
|
type: "snapshot",
|
|
activeTurn: null,
|
|
});
|
|
expect(store.getSession("test-server")?.agents.get("agent-1")?.turn).toEqual({
|
|
phase: "idle",
|
|
cancellationRequestId: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("message submission ordering", () => {
|
|
const agentId = "agent-1";
|
|
const clientMessageId = "client-1";
|
|
const startedAt = new Date("2026-07-31T10:00:01.000Z");
|
|
const steps = [
|
|
"accept",
|
|
"canonical-row",
|
|
"stream-open",
|
|
"snapshot-idle",
|
|
"snapshot-open",
|
|
"stream-close",
|
|
] as const;
|
|
|
|
function applyStep(step: (typeof steps)[number]): void {
|
|
const store = useSessionStore.getState();
|
|
if (step === "accept") {
|
|
store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId);
|
|
return;
|
|
}
|
|
if (step === "canonical-row") {
|
|
store.setAgentStreamState("test-server", agentId, {
|
|
acknowledgedClientMessageIds: [clientMessageId],
|
|
});
|
|
return;
|
|
}
|
|
if (step === "stream-open") {
|
|
applyTestTurn("test-server", agentId, {
|
|
type: "stream_open",
|
|
turn: { turnId: "turn-1", startedAt },
|
|
});
|
|
return;
|
|
}
|
|
if (step === "snapshot-open") {
|
|
applyTestTurn("test-server", agentId, {
|
|
type: "snapshot",
|
|
activeTurn: { turnId: "turn-1", startedAt },
|
|
});
|
|
return;
|
|
}
|
|
if (step === "snapshot-idle") {
|
|
applyTestTurn("test-server", agentId, {
|
|
type: "snapshot",
|
|
activeTurn: null,
|
|
});
|
|
return;
|
|
}
|
|
applyTestTurn("test-server", agentId, {
|
|
type: "stream_close",
|
|
turnId: "turn-1",
|
|
});
|
|
}
|
|
|
|
it("keeps every ordering active until its canonical row and settles at quiescence", () => {
|
|
const continuityViolations: string[] = [];
|
|
const settlementViolations: string[] = [];
|
|
|
|
for (const ordering of permutations(steps)) {
|
|
useSessionStore.getState().clearSession("test-server");
|
|
initializeTestSession();
|
|
useSessionStore
|
|
.getState()
|
|
.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId));
|
|
let canonicalObserved = false;
|
|
|
|
for (const step of ordering) {
|
|
applyStep(step);
|
|
canonicalObserved ||= step === "canonical-row";
|
|
const presentation = selectAgentTurnPresentation(
|
|
useSessionStore.getState().sessions["test-server"],
|
|
agentId,
|
|
);
|
|
if (!canonicalObserved && !presentation.isActive) {
|
|
continuityViolations.push(`${ordering.join(" → ")}: inactive after ${step}`);
|
|
}
|
|
}
|
|
|
|
const store = useSessionStore.getState();
|
|
store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId);
|
|
store.setAgentStreamState("test-server", agentId, {
|
|
acknowledgedClientMessageIds: [clientMessageId],
|
|
});
|
|
applyTestTurn("test-server", agentId, [
|
|
{ type: "stream_close", turnId: "turn-1" },
|
|
{ type: "snapshot", activeTurn: null },
|
|
]);
|
|
const settled = selectAgentTurnPresentation(
|
|
useSessionStore.getState().sessions["test-server"],
|
|
agentId,
|
|
);
|
|
if (settled.isActive) settlementViolations.push(ordering.join(" → "));
|
|
}
|
|
|
|
expect({ continuityViolations, settlementViolations }).toEqual({
|
|
continuityViolations: [],
|
|
settlementViolations: [],
|
|
});
|
|
});
|
|
|
|
it("publishes the optimistic row and active presentation in one store notification", () => {
|
|
initializeTestSession();
|
|
const notifications: Array<{ hasOptimisticRow: boolean; isActive: boolean }> = [];
|
|
const unsubscribe = useSessionStore.subscribe((state) => {
|
|
const session = state.sessions["test-server"];
|
|
notifications.push({
|
|
hasOptimisticRow:
|
|
hasSubmittedMessage(session?.agentStreamTail.get(agentId), clientMessageId) === true,
|
|
isActive: selectAgentTurnPresentation(session, agentId).isActive,
|
|
});
|
|
});
|
|
|
|
useSessionStore
|
|
.getState()
|
|
.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId));
|
|
unsubscribe();
|
|
|
|
expect(notifications).toEqual([{ hasOptimisticRow: true, isActive: true }]);
|
|
});
|
|
|
|
it("keeps another unacknowledged submission active after one row is acknowledged", () => {
|
|
initializeTestSession();
|
|
const store = useSessionStore.getState();
|
|
store.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId));
|
|
store.beginAgentMessageSubmission("test-server", agentId, submittedMessage("client-2"));
|
|
store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId);
|
|
store.setAgentStreamState("test-server", agentId, {
|
|
acknowledgedClientMessageIds: [clientMessageId],
|
|
});
|
|
|
|
expect(
|
|
selectAgentTurnPresentation(useSessionStore.getState().sessions["test-server"], agentId)
|
|
.isActive,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("reconciles a created-agent row without creating a submission transaction", () => {
|
|
initializeTestSession();
|
|
const message = submittedMessage(clientMessageId);
|
|
|
|
const handedOff = useSessionStore
|
|
.getState()
|
|
.handoffCreatedAgentUserMessage("test-server", agentId, message);
|
|
|
|
const session = useSessionStore.getState().sessions["test-server"];
|
|
expect({
|
|
handedOff,
|
|
tail: session?.agentStreamTail.get(agentId),
|
|
head: session?.agentStreamHead.get(agentId) ?? [],
|
|
submissions: session?.messageSubmissions.get(agentId) ?? [],
|
|
}).toEqual({
|
|
handedOff: true,
|
|
tail: [message],
|
|
head: [],
|
|
submissions: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|