457 lines
12 KiB
TypeScript
457 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";
|
|
|
|
interface 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",
|
|
archivingAt: null,
|
|
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 workspaceEntry = useWorkspaceFields(serverId, workspaceId, (entry) =>
|
|
createSidebarWorkspaceEntry({ serverId, workspace: entry }),
|
|
);
|
|
if (workspaceEntry) {
|
|
incrementRecord(counts.rows, workspaceEntry.workspaceId);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function ProjectActiveProbe({
|
|
serverId,
|
|
project,
|
|
counts,
|
|
}: {
|
|
serverId: string;
|
|
project: SidebarProjectEntry;
|
|
counts: RenderCounts;
|
|
}): null {
|
|
useIsNavigationProjectActive({
|
|
serverId,
|
|
workspaceIds: project.workspaces.map((entry) => entry.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((entry) => (
|
|
<React.Fragment key={entry.workspaceKey}>
|
|
<WorkspaceRowProbe
|
|
serverId={entry.serverId}
|
|
workspaceId={entry.workspaceId}
|
|
counts={counts}
|
|
/>
|
|
<WorkspaceSelectionProbe
|
|
serverId={entry.serverId}
|
|
workspaceId={entry.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));
|
|
|
|
const applyRunningScript = (current: Parameters<typeof patchWorkspaceScripts>[0]) =>
|
|
patchWorkspaceScripts(current, {
|
|
workspaceId: "a-main",
|
|
scripts: [{ ...runningScript }],
|
|
});
|
|
|
|
act(() => {
|
|
useSessionStore.getState().setWorkspaces(SERVER_ID, applyRunningScript);
|
|
});
|
|
|
|
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,
|
|
});
|
|
});
|
|
});
|