Scripts launched from the UI now run in a dedicated terminal per script (titled after the script) and reuse the same terminal across re-runs. Script lifecycle is driven by a new onCommandFinished signal sourced from OSC 633 command-finished events emitted by the zsh shell integration, so foreground script exit updates status without killing the terminal. Script payloads now carry terminalId so the workspace can surface the associated tab from the scripts dropdown.
455 lines
12 KiB
TypeScript
455 lines
12 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*/
|
|
import { act } from "@testing-library/react";
|
|
import type { DaemonClient } from "@server/client/daemon-client";
|
|
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import React from "react";
|
|
import type { ReactElement } from "react";
|
|
|
|
vi.hoisted(() => {
|
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
|
});
|
|
|
|
import {
|
|
createSidebarWorkspaceEntry,
|
|
type SidebarProjectEntry,
|
|
} from "@/hooks/use-sidebar-workspaces-list";
|
|
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
|
|
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
|
import {
|
|
getHostRuntimeStore,
|
|
type HostRuntimeController,
|
|
type HostRuntimeSnapshot,
|
|
} from "@/runtime/host-runtime";
|
|
import type { HostProfile } from "@/types/host-connection";
|
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
|
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
|
import {
|
|
activateNavigationWorkspaceSelection,
|
|
syncNavigationActiveWorkspace,
|
|
useIsNavigationProjectActive,
|
|
useIsNavigationWorkspaceSelected,
|
|
} from "@/stores/navigation-active-workspace-store";
|
|
|
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
|
default: {
|
|
getItem: vi.fn(),
|
|
setItem: vi.fn(),
|
|
removeItem: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const SERVER_ID = "sidebar-render-count";
|
|
|
|
type RenderCounts = {
|
|
frame: number;
|
|
headers: Record<string, number>;
|
|
rows: Record<string, number>;
|
|
projectSelection: Record<string, number>;
|
|
rowSelection: Record<string, number>;
|
|
};
|
|
|
|
const runningScript: WorkspaceScriptPayload = {
|
|
scriptName: "web",
|
|
type: "service",
|
|
hostname: "web.paseo.localhost",
|
|
port: 3000,
|
|
proxyUrl: "http://web.paseo.localhost:6767",
|
|
lifecycle: "running",
|
|
health: "healthy",
|
|
exitCode: null,
|
|
terminalId: null,
|
|
};
|
|
|
|
function workspace(input: {
|
|
id: string;
|
|
projectId: string;
|
|
projectDisplayName: string;
|
|
name: string;
|
|
status?: WorkspaceDescriptor["status"];
|
|
scripts?: WorkspaceDescriptor["scripts"];
|
|
}): WorkspaceDescriptor {
|
|
return {
|
|
id: input.id,
|
|
projectId: input.projectId,
|
|
projectDisplayName: input.projectDisplayName,
|
|
projectRootPath: `/repo/${input.projectId}`,
|
|
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
|
|
projectKind: "git",
|
|
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
|
|
name: input.name,
|
|
status: input.status ?? "done",
|
|
diffStat: null,
|
|
scripts: input.scripts ?? [],
|
|
};
|
|
}
|
|
|
|
function createWorkspaces(): WorkspaceDescriptor[] {
|
|
return [
|
|
workspace({
|
|
id: "a-main",
|
|
projectId: "project-a",
|
|
projectDisplayName: "Project A",
|
|
name: "main",
|
|
scripts: [runningScript],
|
|
}),
|
|
workspace({
|
|
id: "a-one",
|
|
projectId: "project-a",
|
|
projectDisplayName: "Project A",
|
|
name: "one",
|
|
}),
|
|
workspace({
|
|
id: "a-two",
|
|
projectId: "project-a",
|
|
projectDisplayName: "Project A",
|
|
name: "two",
|
|
}),
|
|
workspace({
|
|
id: "b-main",
|
|
projectId: "project-b",
|
|
projectDisplayName: "Project B",
|
|
name: "main",
|
|
}),
|
|
workspace({
|
|
id: "b-one",
|
|
projectId: "project-b",
|
|
projectDisplayName: "Project B",
|
|
name: "one",
|
|
}),
|
|
workspace({
|
|
id: "b-two",
|
|
projectId: "project-b",
|
|
projectDisplayName: "Project B",
|
|
name: "two",
|
|
}),
|
|
];
|
|
}
|
|
|
|
function makeHost(): HostProfile {
|
|
const now = "2026-04-19T00:00:00.000Z";
|
|
return {
|
|
serverId: SERVER_ID,
|
|
label: "Render Count Host",
|
|
lifecycle: {},
|
|
connections: [],
|
|
preferredConnectionId: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
|
|
function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
|
|
act(() => {
|
|
getHostRuntimeStore().syncHosts([makeHost()]);
|
|
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
|
useSessionStore
|
|
.getState()
|
|
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
|
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
|
useSidebarOrderStore.setState({
|
|
projectOrderByServerId: {
|
|
[SERVER_ID]: ["project-a", "project-b"],
|
|
},
|
|
workspaceOrderByServerAndProject: {
|
|
[`${SERVER_ID}::project-a`]: [
|
|
`${SERVER_ID}:a-main`,
|
|
`${SERVER_ID}:a-one`,
|
|
`${SERVER_ID}:a-two`,
|
|
],
|
|
[`${SERVER_ID}::project-b`]: [
|
|
`${SERVER_ID}:b-main`,
|
|
`${SERVER_ID}:b-one`,
|
|
`${SERVER_ID}:b-two`,
|
|
],
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
function resetCounts(counts: RenderCounts): void {
|
|
counts.frame = 0;
|
|
counts.headers = {};
|
|
counts.rows = {};
|
|
counts.projectSelection = {};
|
|
counts.rowSelection = {};
|
|
}
|
|
|
|
function incrementRecord(record: Record<string, number>, key: string): void {
|
|
record[key] = (record[key] ?? 0) + 1;
|
|
}
|
|
|
|
function ProjectHeaderProbe({
|
|
project,
|
|
counts,
|
|
}: {
|
|
project: SidebarProjectEntry;
|
|
counts: RenderCounts;
|
|
}): null {
|
|
incrementRecord(counts.headers, project.projectKey);
|
|
return null;
|
|
}
|
|
|
|
function WorkspaceRowProbe({
|
|
serverId,
|
|
workspaceId,
|
|
counts,
|
|
}: {
|
|
serverId: string;
|
|
workspaceId: string;
|
|
counts: RenderCounts;
|
|
}): null {
|
|
const workspace = useWorkspaceFields(serverId, workspaceId, (entry) =>
|
|
createSidebarWorkspaceEntry({ serverId, workspace: entry }),
|
|
);
|
|
if (workspace) {
|
|
incrementRecord(counts.rows, workspace.workspaceId);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function ProjectActiveProbe({
|
|
serverId,
|
|
project,
|
|
counts,
|
|
}: {
|
|
serverId: string;
|
|
project: SidebarProjectEntry;
|
|
counts: RenderCounts;
|
|
}): null {
|
|
useIsNavigationProjectActive({
|
|
serverId,
|
|
workspaceIds: project.workspaces.map((workspace) => workspace.workspaceId),
|
|
});
|
|
incrementRecord(counts.projectSelection, project.projectKey);
|
|
return null;
|
|
}
|
|
|
|
function WorkspaceSelectionProbe({
|
|
serverId,
|
|
workspaceId,
|
|
counts,
|
|
}: {
|
|
serverId: string;
|
|
workspaceId: string;
|
|
counts: RenderCounts;
|
|
}): null {
|
|
useIsNavigationWorkspaceSelected({ serverId, workspaceId });
|
|
incrementRecord(counts.rowSelection, workspaceId);
|
|
return null;
|
|
}
|
|
|
|
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
|
counts.frame += 1;
|
|
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
|
|
|
|
return (
|
|
<>
|
|
{projects.map((project) => (
|
|
<div key={project.projectKey}>
|
|
<ProjectHeaderProbe project={project} counts={counts} />
|
|
<ProjectActiveProbe serverId={SERVER_ID} project={project} counts={counts} />
|
|
{project.workspaces.map((workspace) => (
|
|
<React.Fragment key={workspace.workspaceKey}>
|
|
<WorkspaceRowProbe
|
|
serverId={workspace.serverId}
|
|
workspaceId={workspace.workspaceId}
|
|
counts={counts}
|
|
/>
|
|
<WorkspaceSelectionProbe
|
|
serverId={workspace.serverId}
|
|
workspaceId={workspace.workspaceId}
|
|
counts={counts}
|
|
/>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function getHostController(): HostRuntimeController {
|
|
const controllers = (
|
|
getHostRuntimeStore() as unknown as {
|
|
controllers: Map<string, HostRuntimeController>;
|
|
}
|
|
).controllers;
|
|
const controller = controllers.get(SERVER_ID);
|
|
if (!controller) {
|
|
throw new Error("Host runtime controller was not initialized");
|
|
}
|
|
return controller;
|
|
}
|
|
|
|
function updateControllerSnapshot(
|
|
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
|
|
): void {
|
|
(
|
|
getHostController() as unknown as {
|
|
updateSnapshot: (
|
|
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
|
|
) => void;
|
|
}
|
|
).updateSnapshot(patch);
|
|
}
|
|
|
|
async function renderProbe(counts: RenderCounts): Promise<{ root: Root; container: HTMLElement }> {
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(<SidebarFrameProbe counts={counts} />);
|
|
});
|
|
resetCounts(counts);
|
|
return { root, container };
|
|
}
|
|
|
|
describe("sidebar workspace render isolation", () => {
|
|
let root: Root | null = null;
|
|
let container: HTMLElement | null = null;
|
|
|
|
beforeEach(async () => {
|
|
initializeSidebarState(createWorkspaces());
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (root) {
|
|
act(() => {
|
|
root?.unmount();
|
|
});
|
|
}
|
|
root = null;
|
|
container?.remove();
|
|
container = null;
|
|
act(() => {
|
|
syncNavigationActiveWorkspace({ current: null });
|
|
getHostRuntimeStore().syncHosts([]);
|
|
useSessionStore.getState().clearSession(SERVER_ID);
|
|
useSidebarOrderStore.setState({
|
|
projectOrderByServerId: {},
|
|
workspaceOrderByServerAndProject: {},
|
|
});
|
|
});
|
|
});
|
|
|
|
it("re-renders only the changed workspace row for a status update", async () => {
|
|
const counts: RenderCounts = {
|
|
frame: 0,
|
|
headers: {},
|
|
rows: {},
|
|
projectSelection: {},
|
|
rowSelection: {},
|
|
};
|
|
({ root, container } = await renderProbe(counts));
|
|
|
|
act(() => {
|
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
|
{
|
|
...createWorkspaces()[1]!,
|
|
status: "running",
|
|
},
|
|
]);
|
|
});
|
|
|
|
expect(counts.frame).toBe(0);
|
|
expect(counts.headers).toEqual({});
|
|
expect(counts.rows).toEqual({ "a-one": 1 });
|
|
});
|
|
|
|
it("does not re-render the sidebar for a host-runtime probe tick with no content change", async () => {
|
|
const counts: RenderCounts = {
|
|
frame: 0,
|
|
headers: {},
|
|
rows: {},
|
|
projectSelection: {},
|
|
rowSelection: {},
|
|
};
|
|
({ root, container } = await renderProbe(counts));
|
|
|
|
act(() => {
|
|
const probeByConnectionId = getHostController().getSnapshot().probeByConnectionId;
|
|
updateControllerSnapshot({
|
|
probeByConnectionId: new Map(probeByConnectionId),
|
|
});
|
|
});
|
|
|
|
expect(counts).toEqual({
|
|
frame: 0,
|
|
headers: {},
|
|
rows: {},
|
|
projectSelection: {},
|
|
rowSelection: {},
|
|
});
|
|
});
|
|
|
|
it("does not re-render for a deep-equal scripts patch", async () => {
|
|
const counts: RenderCounts = {
|
|
frame: 0,
|
|
headers: {},
|
|
rows: {},
|
|
projectSelection: {},
|
|
rowSelection: {},
|
|
};
|
|
({ root, container } = await renderProbe(counts));
|
|
|
|
act(() => {
|
|
useSessionStore.getState().setWorkspaces(SERVER_ID, (current) =>
|
|
patchWorkspaceScripts(current, {
|
|
workspaceId: "a-main",
|
|
scripts: [{ ...runningScript }],
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(counts).toEqual({
|
|
frame: 0,
|
|
headers: {},
|
|
rows: {},
|
|
projectSelection: {},
|
|
rowSelection: {},
|
|
});
|
|
});
|
|
|
|
it("isolates active selection updates to affected row and project boolean probes", async () => {
|
|
const counts: RenderCounts = {
|
|
frame: 0,
|
|
headers: {},
|
|
rows: {},
|
|
projectSelection: {},
|
|
rowSelection: {},
|
|
};
|
|
|
|
act(() => {
|
|
activateNavigationWorkspaceSelection({
|
|
serverId: SERVER_ID,
|
|
workspaceId: "a-one",
|
|
});
|
|
});
|
|
({ root, container } = await renderProbe(counts));
|
|
|
|
act(() => {
|
|
activateNavigationWorkspaceSelection({
|
|
serverId: SERVER_ID,
|
|
workspaceId: "b-two",
|
|
});
|
|
});
|
|
|
|
expect(counts.frame).toBe(0);
|
|
expect(counts.headers).toEqual({});
|
|
expect(counts.rows).toEqual({});
|
|
expect(counts.projectSelection).toEqual({
|
|
"project-a": 1,
|
|
"project-b": 1,
|
|
});
|
|
expect(counts.rowSelection).toEqual({
|
|
"a-one": 1,
|
|
"b-two": 1,
|
|
});
|
|
});
|
|
});
|