Skip to content

Commit c460bc8

Browse files
committed
test: add coverage for useCreationWorkspace
1 parent 816297d commit c460bc8

File tree

1 file changed

+395
-0
lines changed

1 file changed

+395
-0
lines changed
Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
import type { DraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings";
2+
import { getModeKey, getProjectScopeId, getThinkingLevelKey } from "@/common/constants/storage";
3+
import type { SendMessageError } from "@/common/types/errors";
4+
import type { BranchListResult, IPCApi, SendMessageOptions } from "@/common/types/ipc";
5+
import type { RuntimeMode } from "@/common/types/runtime";
6+
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
7+
import { act, cleanup, render, waitFor } from "@testing-library/react";
8+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
9+
import { GlobalWindow } from "happy-dom";
10+
import React from "react";
11+
12+
const readPersistedStateCalls: Array<[string, unknown]> = [];
13+
let persistedPreferences: Record<string, unknown> = {};
14+
const readPersistedStateMock = mock((key: string, defaultValue: unknown) => {
15+
readPersistedStateCalls.push([key, defaultValue]);
16+
if (Object.prototype.hasOwnProperty.call(persistedPreferences, key)) {
17+
return persistedPreferences[key];
18+
}
19+
return defaultValue;
20+
});
21+
22+
const updatePersistedStateCalls: Array<[string, unknown]> = [];
23+
const updatePersistedStateMock = mock((key: string, value: unknown) => {
24+
updatePersistedStateCalls.push([key, value]);
25+
});
26+
27+
mock.module("@/browser/hooks/usePersistedState", () => ({
28+
readPersistedState: readPersistedStateMock,
29+
updatePersistedState: updatePersistedStateMock,
30+
}));
31+
32+
type DraftSettingsInvocation = {
33+
projectPath: string;
34+
branches: string[];
35+
recommendedTrunk: string | null;
36+
};
37+
let draftSettingsInvocations: DraftSettingsInvocation[] = [];
38+
let draftSettingsState: DraftSettingsHarness;
39+
const useDraftWorkspaceSettingsMock = mock(
40+
(projectPath: string, branches: string[], recommendedTrunk: string | null) => {
41+
draftSettingsInvocations.push({ projectPath, branches, recommendedTrunk });
42+
if (!draftSettingsState) {
43+
throw new Error("Draft settings state not initialized");
44+
}
45+
return draftSettingsState.snapshot();
46+
}
47+
);
48+
49+
mock.module("@/browser/hooks/useDraftWorkspaceSettings", () => ({
50+
useDraftWorkspaceSettings: useDraftWorkspaceSettingsMock,
51+
}));
52+
53+
let currentSendOptions: SendMessageOptions;
54+
const useSendMessageOptionsMock = mock(() => currentSendOptions);
55+
56+
type WorkspaceSendMessage = IPCApi["workspace"]["sendMessage"];
57+
type WorkspaceSendMessageParams = Parameters<WorkspaceSendMessage>;
58+
type WorkspaceSendMessageResult = Awaited<ReturnType<WorkspaceSendMessage>>;
59+
mock.module("@/browser/hooks/useSendMessageOptions", () => ({
60+
useSendMessageOptions: useSendMessageOptionsMock,
61+
}));
62+
63+
const TEST_PROJECT_PATH = "/projects/demo";
64+
const TEST_WORKSPACE_ID = "ws-created";
65+
const TEST_METADATA: FrontendWorkspaceMetadata = {
66+
id: TEST_WORKSPACE_ID,
67+
name: "demo-branch",
68+
projectName: "Demo",
69+
projectPath: TEST_PROJECT_PATH,
70+
namedWorkspacePath: "/worktrees/demo/demo-branch",
71+
runtimeConfig: { type: "local", srcBaseDir: "/home/user/.mux/src" },
72+
createdAt: "2025-01-01T00:00:00.000Z",
73+
};
74+
75+
import { useCreationWorkspace } from "./useCreationWorkspace";
76+
77+
describe("useCreationWorkspace", () => {
78+
beforeEach(() => {
79+
persistedPreferences = {};
80+
readPersistedStateCalls.length = 0;
81+
updatePersistedStateCalls.length = 0;
82+
draftSettingsInvocations = [];
83+
draftSettingsState = createDraftSettingsHarness();
84+
currentSendOptions = {
85+
model: "gpt-4",
86+
thinkingLevel: "medium",
87+
mode: "exec",
88+
} satisfies SendMessageOptions;
89+
});
90+
91+
afterEach(() => {
92+
cleanup();
93+
// Reset global window/document/localStorage between tests
94+
// @ts-expect-error - test cleanup
95+
globalThis.window = undefined;
96+
// @ts-expect-error - test cleanup
97+
globalThis.document = undefined;
98+
// @ts-expect-error - test cleanup
99+
globalThis.localStorage = undefined;
100+
});
101+
102+
test("loads branches when projectPath is provided", async () => {
103+
const listBranchesMock = mock(async (): Promise<BranchListResult> => ({
104+
branches: ["main", "dev"],
105+
recommendedTrunk: "dev",
106+
}));
107+
const { projectsApi } = setupWindow({ listBranches: listBranchesMock });
108+
const onWorkspaceCreated = mock((metadata: FrontendWorkspaceMetadata) => metadata);
109+
110+
const getHook = renderUseCreationWorkspace({
111+
projectPath: TEST_PROJECT_PATH,
112+
onWorkspaceCreated,
113+
});
114+
115+
await waitFor(() => expect(projectsApi.listBranches.mock.calls.length).toBe(1));
116+
expect(projectsApi.listBranches.mock.calls[0][0]).toBe(TEST_PROJECT_PATH);
117+
118+
await waitFor(() => expect(getHook().branches).toEqual(["main", "dev"]));
119+
expect(draftSettingsInvocations[0]).toEqual({
120+
projectPath: TEST_PROJECT_PATH,
121+
branches: [],
122+
recommendedTrunk: null,
123+
});
124+
expect(draftSettingsInvocations.at(-1)).toEqual({
125+
projectPath: TEST_PROJECT_PATH,
126+
branches: ["main", "dev"],
127+
recommendedTrunk: "dev",
128+
});
129+
expect(getHook().trunkBranch).toBe(draftSettingsState.state.trunkBranch);
130+
});
131+
132+
test("does not load branches when projectPath is empty", async () => {
133+
const listBranchesMock = mock(async (): Promise<BranchListResult> => ({
134+
branches: ["main"],
135+
recommendedTrunk: "main",
136+
}));
137+
setupWindow({ listBranches: listBranchesMock });
138+
const onWorkspaceCreated = mock((metadata: FrontendWorkspaceMetadata) => metadata);
139+
140+
const getHook = renderUseCreationWorkspace({
141+
projectPath: "",
142+
onWorkspaceCreated,
143+
});
144+
145+
await waitFor(() => expect(draftSettingsInvocations.length).toBeGreaterThan(0));
146+
expect(listBranchesMock.mock.calls.length).toBe(0);
147+
expect(getHook().branches).toEqual([]);
148+
});
149+
150+
test("handleSend sends message and syncs preferences on success", async () => {
151+
const listBranchesMock = mock(async (): Promise<BranchListResult> => ({
152+
branches: ["main"],
153+
recommendedTrunk: "main",
154+
}));
155+
const sendMessageMock = mock<WorkspaceSendMessage>(
156+
async (..._args: WorkspaceSendMessageParams): Promise<WorkspaceSendMessageResult> => ({
157+
success: true as const,
158+
workspaceId: TEST_WORKSPACE_ID,
159+
metadata: TEST_METADATA,
160+
})
161+
);
162+
const { workspaceApi } = setupWindow({
163+
listBranches: listBranchesMock,
164+
sendMessage: sendMessageMock,
165+
});
166+
167+
persistedPreferences[getModeKey(getProjectScopeId(TEST_PROJECT_PATH))] = "plan";
168+
persistedPreferences[getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "high";
169+
170+
draftSettingsState = createDraftSettingsHarness({
171+
runtimeMode: "ssh",
172+
sshHost: "example.com",
173+
runtimeString: "ssh example.com",
174+
trunkBranch: "dev",
175+
});
176+
const onWorkspaceCreated = mock((metadata: FrontendWorkspaceMetadata) => metadata);
177+
178+
const getHook = renderUseCreationWorkspace({
179+
projectPath: TEST_PROJECT_PATH,
180+
onWorkspaceCreated,
181+
});
182+
183+
await waitFor(() => expect(getHook().branches).toEqual(["main"]));
184+
185+
await act(async () => {
186+
await getHook().handleSend("launch workspace");
187+
});
188+
189+
expect(workspaceApi.sendMessage.mock.calls.length).toBe(1);
190+
const [workspaceId, message, options] = workspaceApi.sendMessage.mock.calls[0];
191+
expect(workspaceId).toBeNull();
192+
expect(message).toBe("launch workspace");
193+
expect(options?.projectPath).toBe(TEST_PROJECT_PATH);
194+
expect(options?.trunkBranch).toBe("dev");
195+
expect(options?.model).toBe("gpt-4");
196+
expect(options?.mode).toBe("exec");
197+
expect(options?.thinkingLevel).toBe("medium");
198+
expect(options?.runtimeConfig).toEqual({
199+
type: "ssh",
200+
host: "example.com",
201+
srcBaseDir: "~/mux",
202+
});
203+
204+
await waitFor(() => expect(onWorkspaceCreated.mock.calls.length).toBe(1));
205+
expect(onWorkspaceCreated.mock.calls[0][0]).toEqual(TEST_METADATA);
206+
207+
const projectModeKey = getModeKey(getProjectScopeId(TEST_PROJECT_PATH));
208+
const projectThinkingKey = getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH));
209+
expect(readPersistedStateCalls).toContainEqual([projectModeKey, null]);
210+
expect(readPersistedStateCalls).toContainEqual([projectThinkingKey, null]);
211+
212+
const modeKey = getModeKey(TEST_WORKSPACE_ID);
213+
const thinkingKey = getThinkingLevelKey(TEST_WORKSPACE_ID);
214+
expect(updatePersistedStateCalls).toContainEqual([modeKey, "plan"]);
215+
expect(updatePersistedStateCalls).toContainEqual([thinkingKey, "high"]);
216+
});
217+
218+
test("handleSend surfaces backend errors and resets state", async () => {
219+
const sendMessageMock = mock<WorkspaceSendMessage>(
220+
async (..._args: WorkspaceSendMessageParams): Promise<WorkspaceSendMessageResult> => ({
221+
success: false as const,
222+
error: { type: "unknown", raw: "backend exploded" } satisfies SendMessageError,
223+
})
224+
);
225+
setupWindow({ sendMessage: sendMessageMock });
226+
draftSettingsState = createDraftSettingsHarness({ trunkBranch: "dev" });
227+
const onWorkspaceCreated = mock((metadata: FrontendWorkspaceMetadata) => metadata);
228+
229+
const getHook = renderUseCreationWorkspace({
230+
projectPath: TEST_PROJECT_PATH,
231+
onWorkspaceCreated,
232+
});
233+
234+
await act(async () => {
235+
await getHook().handleSend("make workspace");
236+
});
237+
238+
expect(sendMessageMock.mock.calls.length).toBe(1);
239+
expect(onWorkspaceCreated.mock.calls.length).toBe(0);
240+
await waitFor(() => expect(getHook().error).toBe("backend exploded"));
241+
await waitFor(() => expect(getHook().isSending).toBe(false));
242+
expect(updatePersistedStateCalls).toEqual([]);
243+
});
244+
});
245+
246+
type DraftSettingsHarness = ReturnType<typeof createDraftSettingsHarness>;
247+
248+
function createDraftSettingsHarness(
249+
initial?: Partial<{
250+
runtimeMode: RuntimeMode;
251+
sshHost: string;
252+
trunkBranch: string;
253+
runtimeString?: string | undefined;
254+
}>
255+
) {
256+
const state = {
257+
runtimeMode: initial?.runtimeMode ?? ("local" as RuntimeMode),
258+
sshHost: initial?.sshHost ?? "",
259+
trunkBranch: initial?.trunkBranch ?? "main",
260+
runtimeString: initial?.runtimeString,
261+
} satisfies {
262+
runtimeMode: RuntimeMode;
263+
sshHost: string;
264+
trunkBranch: string;
265+
runtimeString: string | undefined;
266+
};
267+
268+
const setRuntimeOptions = mock((mode: RuntimeMode, host: string) => {
269+
state.runtimeMode = mode;
270+
state.sshHost = host;
271+
const trimmedHost = host.trim();
272+
state.runtimeString = mode === "ssh" ? (trimmedHost ? `ssh ${trimmedHost}` : "ssh") : undefined;
273+
});
274+
275+
const setTrunkBranch = mock((branch: string) => {
276+
state.trunkBranch = branch;
277+
});
278+
279+
const getRuntimeString = mock(() => state.runtimeString);
280+
281+
return {
282+
state,
283+
setRuntimeOptions,
284+
setTrunkBranch,
285+
getRuntimeString,
286+
snapshot(): {
287+
settings: DraftWorkspaceSettings;
288+
setRuntimeOptions: typeof setRuntimeOptions;
289+
setTrunkBranch: typeof setTrunkBranch;
290+
getRuntimeString: typeof getRuntimeString;
291+
} {
292+
const settings: DraftWorkspaceSettings = {
293+
model: "gpt-4",
294+
thinkingLevel: "medium",
295+
mode: "exec",
296+
use1M: false,
297+
runtimeMode: state.runtimeMode,
298+
sshHost: state.sshHost,
299+
trunkBranch: state.trunkBranch,
300+
};
301+
return {
302+
settings,
303+
setRuntimeOptions,
304+
setTrunkBranch,
305+
getRuntimeString,
306+
};
307+
},
308+
};
309+
}
310+
311+
type SetupWindowOptions = {
312+
listBranches?: ReturnType<
313+
typeof mock<(projectPath: string) => Promise<BranchListResult>>
314+
>;
315+
sendMessage?: ReturnType<
316+
typeof mock<
317+
(
318+
workspaceId: string | null,
319+
message: string,
320+
options?: Parameters<typeof window.api.workspace.sendMessage>[2]
321+
) => ReturnType<typeof window.api.workspace.sendMessage>
322+
>
323+
>;
324+
};
325+
326+
function setupWindow(options: SetupWindowOptions = {}) {
327+
const windowInstance = new GlobalWindow();
328+
const listBranches =
329+
options.listBranches ??
330+
mock(async (): Promise<BranchListResult> => ({ branches: [], recommendedTrunk: "" }));
331+
const sendMessage =
332+
options.sendMessage ??
333+
mock(
334+
async (
335+
workspaceId: string | null,
336+
message: string,
337+
opts?: Parameters<typeof window.api.workspace.sendMessage>[2]
338+
) =>
339+
({
340+
success: true as const,
341+
workspaceId: TEST_WORKSPACE_ID,
342+
metadata: TEST_METADATA,
343+
})
344+
);
345+
346+
globalThis.window = windowInstance as unknown as typeof globalThis.window;
347+
const windowWithApi = globalThis.window as typeof globalThis.window & { api: IPCApi };
348+
windowWithApi.api = {
349+
projects: {
350+
listBranches,
351+
},
352+
workspace: {
353+
sendMessage,
354+
},
355+
platform: "test",
356+
versions: {
357+
node: "0",
358+
chrome: "0",
359+
electron: "0",
360+
},
361+
} as unknown as typeof windowWithApi.api;
362+
363+
globalThis.document = windowWithApi.document as Document;
364+
globalThis.localStorage = windowWithApi.localStorage;
365+
366+
return {
367+
projectsApi: { listBranches },
368+
workspaceApi: { sendMessage },
369+
};
370+
}
371+
372+
type HookOptions = {
373+
projectPath: string;
374+
onWorkspaceCreated: (metadata: FrontendWorkspaceMetadata) => void;
375+
};
376+
377+
function renderUseCreationWorkspace(options: HookOptions) {
378+
const resultRef: {
379+
current: ReturnType<typeof useCreationWorkspace> | null;
380+
} = { current: null };
381+
382+
function Harness(props: HookOptions) {
383+
resultRef.current = useCreationWorkspace(props);
384+
return null;
385+
}
386+
387+
render(<Harness {...options} />);
388+
389+
return () => {
390+
if (!resultRef.current) {
391+
throw new Error("Hook result not initialized");
392+
}
393+
return resultRef.current;
394+
};
395+
}

0 commit comments

Comments
 (0)