Skip to content

Commit 88a159d

Browse files
authored
Add regression coverage for cleanup and connection flows (#382)
* Add regression coverage for cleanup and connection flows - Cover worktree cleanup candidate selection and native folder picker fallbacks - Add readiness, connection health, sync, and native API hook tests - Include `react-test-renderer` for hook-level test coverage * Add connection health test coverage - add react test renderer types for the web test suite - align connection health tests with numeric timestamps and stable listener iteration * Use default interaction mode in composer draft tests - Import `DEFAULT_INTERACTION_MODE` in the draft store test - Assert project draft thread mapping against the shared default instead of hardcoded `chat`
1 parent e66d255 commit 88a159d

10 files changed

Lines changed: 1184 additions & 2 deletions
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { collectMergedWorktreeCleanupCandidates } from "./worktreeCleanup";
4+
5+
describe("collectMergedWorktreeCleanupCandidates", () => {
6+
it("includes only merged pull request worktrees and ignores root, blank, and branchless entries", () => {
7+
const candidates = collectMergedWorktreeCleanupCandidates({
8+
cwd: "/repo",
9+
worktreeListStdout: [
10+
"worktree /repo/",
11+
"branch refs/heads/feature-root",
12+
"",
13+
"worktree /repo/worktrees/feature-one",
14+
"branch refs/heads/feature-one",
15+
"",
16+
"worktree /repo/worktrees/feature-two",
17+
"branch refs/heads/feature-two",
18+
"prunable gitdir file points to non-existent location",
19+
"",
20+
"worktree /repo/worktrees/no-branch",
21+
"HEAD abcdef1234567890",
22+
"",
23+
"worktree ",
24+
"branch refs/heads/feature-blank-path",
25+
"",
26+
].join("\n"),
27+
mergedPullRequests: [
28+
{
29+
number: 101,
30+
title: "Root PR",
31+
url: "https://example.com/pr/101",
32+
headBranch: "feature-root",
33+
mergedAt: "2026-03-01T00:00:00.000Z",
34+
},
35+
{
36+
number: 102,
37+
title: "Feature one",
38+
url: "https://example.com/pr/102",
39+
headBranch: "feature-one",
40+
mergedAt: "2026-03-02T00:00:00.000Z",
41+
},
42+
{
43+
number: 103,
44+
title: "Feature two",
45+
url: "https://example.com/pr/103",
46+
headBranch: "feature-two",
47+
mergedAt: "2026-03-03T00:00:00.000Z",
48+
},
49+
{
50+
number: 104,
51+
title: "Blank path",
52+
url: "https://example.com/pr/104",
53+
headBranch: "feature-blank-path",
54+
mergedAt: "2026-03-04T00:00:00.000Z",
55+
},
56+
],
57+
});
58+
59+
expect(candidates).toEqual([
60+
{
61+
path: "/repo/worktrees/feature-one",
62+
branch: "feature-one",
63+
prNumber: 102,
64+
prTitle: "Feature one",
65+
prUrl: "https://example.com/pr/102",
66+
mergedAt: "2026-03-02T00:00:00.000Z",
67+
pathExists: true,
68+
prunable: false,
69+
},
70+
{
71+
path: "/repo/worktrees/feature-two",
72+
branch: "feature-two",
73+
prNumber: 103,
74+
prTitle: "Feature two",
75+
prUrl: "https://example.com/pr/103",
76+
mergedAt: "2026-03-03T00:00:00.000Z",
77+
pathExists: false,
78+
prunable: true,
79+
},
80+
]);
81+
});
82+
83+
it("marks prunable entries as missing paths", () => {
84+
const [candidate] = collectMergedWorktreeCleanupCandidates({
85+
cwd: "/repo",
86+
worktreeListStdout: [
87+
"worktree /repo/worktrees/feature-missing",
88+
"branch refs/heads/feature-missing",
89+
"prunable gitdir file points to non-existent location",
90+
"",
91+
].join("\n"),
92+
mergedPullRequests: [
93+
{
94+
number: 201,
95+
title: "Missing worktree",
96+
url: "https://example.com/pr/201",
97+
headBranch: "feature-missing",
98+
mergedAt: "2026-03-01T00:00:00.000Z",
99+
},
100+
],
101+
});
102+
103+
expect(candidate).toMatchObject({
104+
branch: "feature-missing",
105+
pathExists: false,
106+
prunable: true,
107+
});
108+
});
109+
110+
it("sorts by existing paths first, then mergedAt descending, then branch name", () => {
111+
const candidates = collectMergedWorktreeCleanupCandidates({
112+
cwd: "/repo",
113+
worktreeListStdout: [
114+
"worktree /repo/worktrees/feature-beta",
115+
"branch refs/heads/feature-beta",
116+
"",
117+
"worktree /repo/worktrees/feature-missing",
118+
"branch refs/heads/feature-missing",
119+
"prunable gitdir file points to non-existent location",
120+
"",
121+
"worktree /repo/worktrees/feature-recent",
122+
"branch refs/heads/feature-recent",
123+
"",
124+
"worktree /repo/worktrees/feature-alpha",
125+
"branch refs/heads/feature-alpha",
126+
"",
127+
].join("\n"),
128+
mergedPullRequests: [
129+
{
130+
number: 301,
131+
title: "Feature beta",
132+
url: "https://example.com/pr/301",
133+
headBranch: "feature-beta",
134+
mergedAt: "2026-03-01T12:00:00.000Z",
135+
},
136+
{
137+
number: 302,
138+
title: "Feature missing",
139+
url: "https://example.com/pr/302",
140+
headBranch: "feature-missing",
141+
mergedAt: "2026-04-01T12:00:00.000Z",
142+
},
143+
{
144+
number: 303,
145+
title: "Feature recent",
146+
url: "https://example.com/pr/303",
147+
headBranch: "feature-recent",
148+
mergedAt: "2026-03-02T12:00:00.000Z",
149+
},
150+
{
151+
number: 304,
152+
title: "Feature alpha",
153+
url: "https://example.com/pr/304",
154+
headBranch: "feature-alpha",
155+
mergedAt: "2026-03-01T12:00:00.000Z",
156+
},
157+
],
158+
});
159+
160+
expect(candidates.map((candidate) => candidate.branch)).toEqual([
161+
"feature-recent",
162+
"feature-alpha",
163+
"feature-beta",
164+
"feature-missing",
165+
]);
166+
});
167+
});
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
interface NativeFolderPickerModule {
4+
pickFolderNative: () => string | null;
5+
}
6+
7+
async function loadNativeFolderPicker(platformName: string) {
8+
const spawnSyncMock = vi.fn();
9+
const execFileSyncMock = vi.fn();
10+
11+
vi.resetModules();
12+
vi.doMock("node:os", () => ({
13+
platform: () => platformName,
14+
}));
15+
vi.doMock("node:child_process", () => ({
16+
execFileSync: execFileSyncMock,
17+
spawnSync: spawnSyncMock,
18+
}));
19+
20+
const module = (await import("./nativeFolderPicker")) as NativeFolderPickerModule;
21+
22+
return {
23+
execFileSyncMock,
24+
pickFolderNative: module.pickFolderNative,
25+
spawnSyncMock,
26+
};
27+
}
28+
29+
afterEach(() => {
30+
vi.resetModules();
31+
vi.restoreAllMocks();
32+
});
33+
34+
describe("pickFolderNative", () => {
35+
it("returns a trimmed macOS folder path from osascript", async () => {
36+
const { pickFolderNative, spawnSyncMock } = await loadNativeFolderPicker("darwin");
37+
spawnSyncMock.mockReturnValue({
38+
error: undefined,
39+
status: 0,
40+
stdout: "/tmp/project/\n",
41+
});
42+
43+
expect(pickFolderNative()).toBe("/tmp/project/");
44+
expect(spawnSyncMock).toHaveBeenCalledWith(
45+
"osascript",
46+
["-e", 'POSIX path of (choose folder with prompt "Select project folder")'],
47+
{ encoding: "utf8", timeout: 120_000 },
48+
);
49+
});
50+
51+
it("returns null when the macOS picker fails", async () => {
52+
const { pickFolderNative, spawnSyncMock } = await loadNativeFolderPicker("darwin");
53+
spawnSyncMock.mockReturnValue({
54+
error: new Error("spawn failed"),
55+
status: 1,
56+
stdout: "",
57+
});
58+
59+
expect(pickFolderNative()).toBeNull();
60+
});
61+
62+
it("returns a trimmed Windows folder path from PowerShell", async () => {
63+
const { execFileSyncMock, pickFolderNative } = await loadNativeFolderPicker("win32");
64+
execFileSyncMock.mockReturnValue("C:\\Users\\okcode\\project\r\n");
65+
66+
expect(pickFolderNative()).toBe("C:\\Users\\okcode\\project");
67+
expect(execFileSyncMock).toHaveBeenCalledWith(
68+
"powershell.exe",
69+
[
70+
"-NoProfile",
71+
"-NonInteractive",
72+
"-Command",
73+
"Add-Type -AssemblyName System.Windows.Forms; $d=New-Object System.Windows.Forms.FolderBrowserDialog; $d.Description='Select project folder'; if($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){ $d.SelectedPath }",
74+
],
75+
{ encoding: "utf8", timeout: 120_000, windowsHide: true, maxBuffer: 4096 },
76+
);
77+
});
78+
79+
it("returns null when the Windows picker throws", async () => {
80+
const { execFileSyncMock, pickFolderNative } = await loadNativeFolderPicker("win32");
81+
execFileSyncMock.mockImplementation(() => {
82+
throw new Error("powershell failed");
83+
});
84+
85+
expect(pickFolderNative()).toBeNull();
86+
});
87+
88+
it("prefers zenity on Linux when it succeeds", async () => {
89+
const { pickFolderNative, spawnSyncMock } = await loadNativeFolderPicker("linux");
90+
spawnSyncMock.mockReturnValueOnce({
91+
error: undefined,
92+
status: 0,
93+
stdout: "/tmp/linux-project\n",
94+
});
95+
96+
expect(pickFolderNative()).toBe("/tmp/linux-project");
97+
expect(spawnSyncMock).toHaveBeenCalledTimes(1);
98+
expect(spawnSyncMock).toHaveBeenCalledWith(
99+
"zenity",
100+
["--file-selection", "--directory", "--title=Select project folder"],
101+
{
102+
encoding: "utf8",
103+
timeout: 120_000,
104+
},
105+
);
106+
});
107+
108+
it("falls back to kdialog on Linux when zenity fails", async () => {
109+
const { pickFolderNative, spawnSyncMock } = await loadNativeFolderPicker("linux");
110+
spawnSyncMock
111+
.mockReturnValueOnce({
112+
error: new Error("zenity missing"),
113+
status: 1,
114+
stdout: "",
115+
})
116+
.mockReturnValueOnce({
117+
error: undefined,
118+
status: 0,
119+
stdout: "/tmp/kdialog-project\n",
120+
});
121+
122+
expect(pickFolderNative()).toBe("/tmp/kdialog-project");
123+
expect(spawnSyncMock).toHaveBeenNthCalledWith(
124+
2,
125+
"kdialog",
126+
["--getexistingdirectory", ".", "--title", "Select project folder"],
127+
{ encoding: "utf8", timeout: 120_000 },
128+
);
129+
});
130+
131+
it("returns null on Linux when both pickers fail", async () => {
132+
const { pickFolderNative, spawnSyncMock } = await loadNativeFolderPicker("linux");
133+
spawnSyncMock
134+
.mockReturnValueOnce({
135+
error: new Error("zenity missing"),
136+
status: 1,
137+
stdout: "",
138+
})
139+
.mockReturnValueOnce({
140+
error: new Error("kdialog missing"),
141+
status: 1,
142+
stdout: "",
143+
});
144+
145+
expect(pickFolderNative()).toBeNull();
146+
});
147+
148+
it("returns null on unsupported platforms without invoking child processes", async () => {
149+
const { execFileSyncMock, pickFolderNative, spawnSyncMock } =
150+
await loadNativeFolderPicker("freebsd");
151+
152+
expect(pickFolderNative()).toBeNull();
153+
expect(spawnSyncMock).not.toHaveBeenCalled();
154+
expect(execFileSyncMock).not.toHaveBeenCalled();
155+
});
156+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Effect, Fiber } from "effect";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { makeServerReadiness } from "./readiness";
5+
6+
describe("makeServerReadiness", () => {
7+
it("stays pending until all readiness markers complete", async () => {
8+
await Effect.runPromise(
9+
Effect.gen(function* () {
10+
const readiness = yield* makeServerReadiness;
11+
const readyFiber = yield* readiness.awaitServerReady.pipe(Effect.forkScoped);
12+
13+
expect(readyFiber.pollUnsafe()).toBeUndefined();
14+
15+
yield* readiness.markHttpListening;
16+
expect(readyFiber.pollUnsafe()).toBeUndefined();
17+
18+
yield* readiness.markPushBusReady;
19+
expect(readyFiber.pollUnsafe()).toBeUndefined();
20+
21+
yield* readiness.markKeybindingsReady;
22+
expect(readyFiber.pollUnsafe()).toBeUndefined();
23+
24+
yield* readiness.markTerminalSubscriptionsReady;
25+
expect(readyFiber.pollUnsafe()).toBeUndefined();
26+
27+
yield* readiness.markOrchestrationSubscriptionsReady;
28+
yield* Fiber.join(readyFiber);
29+
30+
expect(readyFiber.pollUnsafe()).not.toBeUndefined();
31+
}).pipe(Effect.scoped),
32+
);
33+
});
34+
35+
it("resolves regardless of the order markers complete", async () => {
36+
await Effect.runPromise(
37+
Effect.gen(function* () {
38+
const readiness = yield* makeServerReadiness;
39+
const readyFiber = yield* readiness.awaitServerReady.pipe(Effect.forkScoped);
40+
41+
yield* readiness.markOrchestrationSubscriptionsReady;
42+
expect(readyFiber.pollUnsafe()).toBeUndefined();
43+
44+
yield* readiness.markTerminalSubscriptionsReady;
45+
expect(readyFiber.pollUnsafe()).toBeUndefined();
46+
47+
yield* readiness.markKeybindingsReady;
48+
expect(readyFiber.pollUnsafe()).toBeUndefined();
49+
50+
yield* readiness.markPushBusReady;
51+
expect(readyFiber.pollUnsafe()).toBeUndefined();
52+
53+
yield* readiness.markHttpListening;
54+
yield* Fiber.join(readyFiber);
55+
56+
expect(readyFiber.pollUnsafe()).not.toBeUndefined();
57+
}).pipe(Effect.scoped),
58+
);
59+
});
60+
});

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,13 @@
5959
"@types/babel__core": "^7.20.5",
6060
"@types/react": "^19.0.0",
6161
"@types/react-dom": "^19.0.0",
62+
"@types/react-test-renderer": "^19.0.0",
6263
"@vitejs/plugin-react": "^6.0.0",
6364
"@vitest/browser-playwright": "^4.0.18",
6465
"babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
6566
"msw": "2.12.11",
6667
"playwright": "^1.58.2",
68+
"react-test-renderer": "^19.0.0",
6769
"tailwindcss": "^4.0.0",
6870
"typescript": "catalog:",
6971
"vite": "^8.0.0",

0 commit comments

Comments
 (0)