diff --git a/apps/server/src/git/worktreeCleanup.test.ts b/apps/server/src/git/worktreeCleanup.test.ts new file mode 100644 index 000000000..860c84591 --- /dev/null +++ b/apps/server/src/git/worktreeCleanup.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; + +import { collectMergedWorktreeCleanupCandidates } from "./worktreeCleanup"; + +describe("collectMergedWorktreeCleanupCandidates", () => { + it("includes only merged pull request worktrees and ignores root, blank, and branchless entries", () => { + const candidates = collectMergedWorktreeCleanupCandidates({ + cwd: "/repo", + worktreeListStdout: [ + "worktree /repo/", + "branch refs/heads/feature-root", + "", + "worktree /repo/worktrees/feature-one", + "branch refs/heads/feature-one", + "", + "worktree /repo/worktrees/feature-two", + "branch refs/heads/feature-two", + "prunable gitdir file points to non-existent location", + "", + "worktree /repo/worktrees/no-branch", + "HEAD abcdef1234567890", + "", + "worktree ", + "branch refs/heads/feature-blank-path", + "", + ].join("\n"), + mergedPullRequests: [ + { + number: 101, + title: "Root PR", + url: "https://example.com/pr/101", + headBranch: "feature-root", + mergedAt: "2026-03-01T00:00:00.000Z", + }, + { + number: 102, + title: "Feature one", + url: "https://example.com/pr/102", + headBranch: "feature-one", + mergedAt: "2026-03-02T00:00:00.000Z", + }, + { + number: 103, + title: "Feature two", + url: "https://example.com/pr/103", + headBranch: "feature-two", + mergedAt: "2026-03-03T00:00:00.000Z", + }, + { + number: 104, + title: "Blank path", + url: "https://example.com/pr/104", + headBranch: "feature-blank-path", + mergedAt: "2026-03-04T00:00:00.000Z", + }, + ], + }); + + expect(candidates).toEqual([ + { + path: "/repo/worktrees/feature-one", + branch: "feature-one", + prNumber: 102, + prTitle: "Feature one", + prUrl: "https://example.com/pr/102", + mergedAt: "2026-03-02T00:00:00.000Z", + pathExists: true, + prunable: false, + }, + { + path: "/repo/worktrees/feature-two", + branch: "feature-two", + prNumber: 103, + prTitle: "Feature two", + prUrl: "https://example.com/pr/103", + mergedAt: "2026-03-03T00:00:00.000Z", + pathExists: false, + prunable: true, + }, + ]); + }); + + it("marks prunable entries as missing paths", () => { + const [candidate] = collectMergedWorktreeCleanupCandidates({ + cwd: "/repo", + worktreeListStdout: [ + "worktree /repo/worktrees/feature-missing", + "branch refs/heads/feature-missing", + "prunable gitdir file points to non-existent location", + "", + ].join("\n"), + mergedPullRequests: [ + { + number: 201, + title: "Missing worktree", + url: "https://example.com/pr/201", + headBranch: "feature-missing", + mergedAt: "2026-03-01T00:00:00.000Z", + }, + ], + }); + + expect(candidate).toMatchObject({ + branch: "feature-missing", + pathExists: false, + prunable: true, + }); + }); + + it("sorts by existing paths first, then mergedAt descending, then branch name", () => { + const candidates = collectMergedWorktreeCleanupCandidates({ + cwd: "/repo", + worktreeListStdout: [ + "worktree /repo/worktrees/feature-beta", + "branch refs/heads/feature-beta", + "", + "worktree /repo/worktrees/feature-missing", + "branch refs/heads/feature-missing", + "prunable gitdir file points to non-existent location", + "", + "worktree /repo/worktrees/feature-recent", + "branch refs/heads/feature-recent", + "", + "worktree /repo/worktrees/feature-alpha", + "branch refs/heads/feature-alpha", + "", + ].join("\n"), + mergedPullRequests: [ + { + number: 301, + title: "Feature beta", + url: "https://example.com/pr/301", + headBranch: "feature-beta", + mergedAt: "2026-03-01T12:00:00.000Z", + }, + { + number: 302, + title: "Feature missing", + url: "https://example.com/pr/302", + headBranch: "feature-missing", + mergedAt: "2026-04-01T12:00:00.000Z", + }, + { + number: 303, + title: "Feature recent", + url: "https://example.com/pr/303", + headBranch: "feature-recent", + mergedAt: "2026-03-02T12:00:00.000Z", + }, + { + number: 304, + title: "Feature alpha", + url: "https://example.com/pr/304", + headBranch: "feature-alpha", + mergedAt: "2026-03-01T12:00:00.000Z", + }, + ], + }); + + expect(candidates.map((candidate) => candidate.branch)).toEqual([ + "feature-recent", + "feature-alpha", + "feature-beta", + "feature-missing", + ]); + }); +}); diff --git a/apps/server/src/nativeFolderPicker.test.ts b/apps/server/src/nativeFolderPicker.test.ts new file mode 100644 index 000000000..27bfde422 --- /dev/null +++ b/apps/server/src/nativeFolderPicker.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +interface NativeFolderPickerModule { + pickFolderNative: () => string | null; +} + +async function loadNativeFolderPicker(platformName: string) { + const spawnSyncMock = vi.fn(); + const execFileSyncMock = vi.fn(); + + vi.resetModules(); + vi.doMock("node:os", () => ({ + platform: () => platformName, + })); + vi.doMock("node:child_process", () => ({ + execFileSync: execFileSyncMock, + spawnSync: spawnSyncMock, + })); + + const module = (await import("./nativeFolderPicker")) as NativeFolderPickerModule; + + return { + execFileSyncMock, + pickFolderNative: module.pickFolderNative, + spawnSyncMock, + }; +} + +afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); +}); + +describe("pickFolderNative", () => { + it("returns a trimmed macOS folder path from osascript", async () => { + const { pickFolderNative, spawnSyncMock } = await loadNativeFolderPicker("darwin"); + spawnSyncMock.mockReturnValue({ + error: undefined, + status: 0, + stdout: "/tmp/project/\n", + }); + + expect(pickFolderNative()).toBe("/tmp/project/"); + expect(spawnSyncMock).toHaveBeenCalledWith( + "osascript", + ["-e", 'POSIX path of (choose folder with prompt "Select project folder")'], + { encoding: "utf8", timeout: 120_000 }, + ); + }); + + it("returns null when the macOS picker fails", async () => { + const { pickFolderNative, spawnSyncMock } = await loadNativeFolderPicker("darwin"); + spawnSyncMock.mockReturnValue({ + error: new Error("spawn failed"), + status: 1, + stdout: "", + }); + + expect(pickFolderNative()).toBeNull(); + }); + + it("returns a trimmed Windows folder path from PowerShell", async () => { + const { execFileSyncMock, pickFolderNative } = await loadNativeFolderPicker("win32"); + execFileSyncMock.mockReturnValue("C:\\Users\\okcode\\project\r\n"); + + expect(pickFolderNative()).toBe("C:\\Users\\okcode\\project"); + expect(execFileSyncMock).toHaveBeenCalledWith( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + "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 }", + ], + { encoding: "utf8", timeout: 120_000, windowsHide: true, maxBuffer: 4096 }, + ); + }); + + it("returns null when the Windows picker throws", async () => { + const { execFileSyncMock, pickFolderNative } = await loadNativeFolderPicker("win32"); + execFileSyncMock.mockImplementation(() => { + throw new Error("powershell failed"); + }); + + expect(pickFolderNative()).toBeNull(); + }); + + it("prefers zenity on Linux when it succeeds", async () => { + const { pickFolderNative, spawnSyncMock } = await loadNativeFolderPicker("linux"); + spawnSyncMock.mockReturnValueOnce({ + error: undefined, + status: 0, + stdout: "/tmp/linux-project\n", + }); + + expect(pickFolderNative()).toBe("/tmp/linux-project"); + expect(spawnSyncMock).toHaveBeenCalledTimes(1); + expect(spawnSyncMock).toHaveBeenCalledWith( + "zenity", + ["--file-selection", "--directory", "--title=Select project folder"], + { + encoding: "utf8", + timeout: 120_000, + }, + ); + }); + + it("falls back to kdialog on Linux when zenity fails", async () => { + const { pickFolderNative, spawnSyncMock } = await loadNativeFolderPicker("linux"); + spawnSyncMock + .mockReturnValueOnce({ + error: new Error("zenity missing"), + status: 1, + stdout: "", + }) + .mockReturnValueOnce({ + error: undefined, + status: 0, + stdout: "/tmp/kdialog-project\n", + }); + + expect(pickFolderNative()).toBe("/tmp/kdialog-project"); + expect(spawnSyncMock).toHaveBeenNthCalledWith( + 2, + "kdialog", + ["--getexistingdirectory", ".", "--title", "Select project folder"], + { encoding: "utf8", timeout: 120_000 }, + ); + }); + + it("returns null on Linux when both pickers fail", async () => { + const { pickFolderNative, spawnSyncMock } = await loadNativeFolderPicker("linux"); + spawnSyncMock + .mockReturnValueOnce({ + error: new Error("zenity missing"), + status: 1, + stdout: "", + }) + .mockReturnValueOnce({ + error: new Error("kdialog missing"), + status: 1, + stdout: "", + }); + + expect(pickFolderNative()).toBeNull(); + }); + + it("returns null on unsupported platforms without invoking child processes", async () => { + const { execFileSyncMock, pickFolderNative, spawnSyncMock } = + await loadNativeFolderPicker("freebsd"); + + expect(pickFolderNative()).toBeNull(); + expect(spawnSyncMock).not.toHaveBeenCalled(); + expect(execFileSyncMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/wsServer/readiness.test.ts b/apps/server/src/wsServer/readiness.test.ts new file mode 100644 index 000000000..091dec954 --- /dev/null +++ b/apps/server/src/wsServer/readiness.test.ts @@ -0,0 +1,60 @@ +import { Effect, Fiber } from "effect"; +import { describe, expect, it } from "vitest"; + +import { makeServerReadiness } from "./readiness"; + +describe("makeServerReadiness", () => { + it("stays pending until all readiness markers complete", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const readiness = yield* makeServerReadiness; + const readyFiber = yield* readiness.awaitServerReady.pipe(Effect.forkScoped); + + expect(readyFiber.pollUnsafe()).toBeUndefined(); + + yield* readiness.markHttpListening; + expect(readyFiber.pollUnsafe()).toBeUndefined(); + + yield* readiness.markPushBusReady; + expect(readyFiber.pollUnsafe()).toBeUndefined(); + + yield* readiness.markKeybindingsReady; + expect(readyFiber.pollUnsafe()).toBeUndefined(); + + yield* readiness.markTerminalSubscriptionsReady; + expect(readyFiber.pollUnsafe()).toBeUndefined(); + + yield* readiness.markOrchestrationSubscriptionsReady; + yield* Fiber.join(readyFiber); + + expect(readyFiber.pollUnsafe()).not.toBeUndefined(); + }).pipe(Effect.scoped), + ); + }); + + it("resolves regardless of the order markers complete", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const readiness = yield* makeServerReadiness; + const readyFiber = yield* readiness.awaitServerReady.pipe(Effect.forkScoped); + + yield* readiness.markOrchestrationSubscriptionsReady; + expect(readyFiber.pollUnsafe()).toBeUndefined(); + + yield* readiness.markTerminalSubscriptionsReady; + expect(readyFiber.pollUnsafe()).toBeUndefined(); + + yield* readiness.markKeybindingsReady; + expect(readyFiber.pollUnsafe()).toBeUndefined(); + + yield* readiness.markPushBusReady; + expect(readyFiber.pollUnsafe()).toBeUndefined(); + + yield* readiness.markHttpListening; + yield* Fiber.join(readyFiber); + + expect(readyFiber.pollUnsafe()).not.toBeUndefined(); + }).pipe(Effect.scoped), + ); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 8ddb2b282..a87250dd7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -59,11 +59,13 @@ "@types/babel__core": "^7.20.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/react-test-renderer": "^19.0.0", "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", "msw": "2.12.11", "playwright": "^1.58.2", + "react-test-renderer": "^19.0.0", "tailwindcss": "^4.0.0", "typescript": "catalog:", "vite": "^8.0.0", diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 4d24ba7b0..ad35601e2 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -14,6 +14,7 @@ import { type TerminalContextDraft, } from "./lib/terminalContext"; import { createDebouncedStorage } from "./lib/storage"; +import { DEFAULT_INTERACTION_MODE } from "./types"; function makeImage(input: { id: string; @@ -483,7 +484,7 @@ describe("composerDraftStore project draft thread mapping", () => { worktreePath: "/tmp/worktree-test", envMode: "worktree", runtimeMode: "full-access", - interactionMode: "chat", + interactionMode: DEFAULT_INTERACTION_MODE, createdAt: "2026-01-01T00:00:00.000Z", }); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({ @@ -493,7 +494,7 @@ describe("composerDraftStore project draft thread mapping", () => { worktreePath: "/tmp/worktree-test", envMode: "worktree", runtimeMode: "full-access", - interactionMode: "chat", + interactionMode: DEFAULT_INTERACTION_MODE, createdAt: "2026-01-01T00:00:00.000Z", }); }); diff --git a/apps/web/src/hooks/useAutoDeleteMergedThreads.test.tsx b/apps/web/src/hooks/useAutoDeleteMergedThreads.test.tsx new file mode 100644 index 000000000..7a8dd8840 --- /dev/null +++ b/apps/web/src/hooks/useAutoDeleteMergedThreads.test.tsx @@ -0,0 +1,368 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { ProjectId, ThreadId } from "@okcode/contracts"; + +import { AppSettingsSchema, type AppSettings } from "../appSettings"; +import { gitQueryKeys } from "../lib/gitReactQuery"; +import { useStore } from "../store"; +import { useAutoDeleteMergedThreads } from "./useAutoDeleteMergedThreads"; + +const { + readNativeApiMock, + newCommandIdMock, + toastAddMock, + toastCloseMock, + useQueriesMock, + useQueryClientMock, + invalidateQueriesMock, +} = vi.hoisted(() => ({ + readNativeApiMock: vi.fn(), + newCommandIdMock: vi.fn(), + toastAddMock: vi.fn(), + toastCloseMock: vi.fn(), + useQueriesMock: vi.fn(), + useQueryClientMock: vi.fn(), + invalidateQueriesMock: vi.fn(), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQueries: useQueriesMock, + useQueryClient: useQueryClientMock, + }; +}); + +vi.mock("../nativeApi", () => ({ + readNativeApi: readNativeApiMock, +})); + +vi.mock("../lib/utils", () => ({ + newCommandId: newCommandIdMock, +})); + +vi.mock("../components/ui/toast", () => ({ + toastManager: { + add: toastAddMock, + close: toastCloseMock, + }, +})); + +interface StatusQueryResult { + data?: { + pr?: { + state?: string | null; + } | null; + }; +} + +interface ToastCall { + type: string; + title: string; + description?: string; + actionProps?: { + children: string; + onClick: () => void; + }; +} + +const baseStoreState = useStore.getState(); +const projectId = ProjectId.makeUnsafe("project-1"); +const threadId = ThreadId.makeUnsafe("thread-1"); +const threadTwoId = ThreadId.makeUnsafe("thread-2"); + +let renderer: ReactTestRenderer | null = null; +let statusQueriesResult: StatusQueryResult[] = []; +let toastCalls: ToastCall[] = []; +let dispatchCommandMock = vi.fn(); +let closeTerminalMock = vi.fn(); +let removeWorktreeMock = vi.fn(); +let consoleErrorSpy: ReturnType | null = null; +const originalConsoleError = console.error; + +function HookHarness({ settings }: { settings: AppSettings }) { + useAutoDeleteMergedThreads(settings); + return null; +} + +function makeSettings(overrides: Partial = {}): AppSettings { + return AppSettingsSchema.makeUnsafe({ + autoDeleteMergedThreads: true, + autoDeleteMergedThreadsDelayMinutes: 1, + ...overrides, + }); +} + +function seedStore(input?: { sharedWorktreePath?: string | null }) { + const worktreePath = "/workspace/.okcode/thread-1"; + const secondWorktreePath = input?.sharedWorktreePath ?? "/workspace/.okcode/thread-2"; + + useStore.setState({ + projects: [ + { + id: projectId, + name: "OK Code", + cwd: "/workspace", + model: "gpt-5.4", + expanded: true, + scripts: [], + }, + ], + threads: [ + { + id: threadId, + codexThreadId: null, + projectId, + title: "Merged worktree", + model: "gpt-5.4", + runtimeMode: "full-access", + interactionMode: "chat", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-04-09T20:00:00.000Z", + updatedAt: "2026-04-09T20:00:00.000Z", + latestTurn: null, + branch: "feature/merged", + worktreePath, + turnDiffSummaries: [], + activities: [], + }, + { + id: threadTwoId, + codexThreadId: null, + projectId, + title: "Neighbor thread", + model: "gpt-5.4", + runtimeMode: "full-access", + interactionMode: "chat", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-04-09T20:01:00.000Z", + updatedAt: "2026-04-09T20:01:00.000Z", + latestTurn: null, + branch: "feature/open", + worktreePath: secondWorktreePath, + turnDiffSummaries: [], + activities: [], + }, + ], + threadsHydrated: true, + }); +} + +function buildNativeApi() { + dispatchCommandMock = vi.fn().mockResolvedValue(undefined); + closeTerminalMock = vi.fn().mockResolvedValue(undefined); + removeWorktreeMock = vi.fn().mockResolvedValue(undefined); + + return { + orchestration: { + dispatchCommand: dispatchCommandMock, + }, + terminal: { + close: closeTerminalMock, + }, + git: { + removeWorktree: removeWorktreeMock, + }, + }; +} + +async function mountHook(settings: AppSettings) { + await act(async () => { + renderer = create(); + }); +} + +async function updateHook(settings: AppSettings) { + await act(async () => { + renderer?.update(); + }); +} + +async function unmountHook() { + if (!renderer) { + return; + } + + await act(async () => { + renderer?.unmount(); + }); + renderer = null; +} + +async function flushAsyncWork() { + await Promise.resolve(); + await Promise.resolve(); +} + +async function advanceTime(ms: number) { + await act(async () => { + vi.advanceTimersByTime(ms); + await flushAsyncWork(); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation((message, ...args) => { + if (typeof message === "string" && message.includes("react-test-renderer is deprecated")) { + return; + } + originalConsoleError.call(console, message, ...args); + }); + + renderer = null; + statusQueriesResult = []; + toastCalls = []; + + useStore.setState({ + projects: baseStoreState.projects, + threads: baseStoreState.threads, + threadsHydrated: baseStoreState.threadsHydrated, + }); + + seedStore(); + vi.clearAllTimers(); + + readNativeApiMock.mockReset().mockReturnValue(buildNativeApi()); + newCommandIdMock.mockReset(); + newCommandIdMock.mockImplementation( + (() => { + let commandCounter = 0; + return () => `command-${++commandCounter}`; + })(), + ); + + toastAddMock.mockReset().mockImplementation((input: ToastCall) => { + toastCalls.push(input); + return `toast-${toastCalls.length}`; + }); + toastCloseMock.mockReset(); + + invalidateQueriesMock.mockReset().mockResolvedValue(undefined); + useQueryClientMock.mockReset().mockReturnValue({ + invalidateQueries: invalidateQueriesMock, + }); + useQueriesMock.mockReset().mockImplementation(() => statusQueriesResult); +}); + +afterEach(async () => { + await unmountHook(); + consoleErrorSpy?.mockRestore(); + consoleErrorSpy = null; + useStore.setState({ + projects: baseStoreState.projects, + threads: baseStoreState.threads, + threadsHydrated: baseStoreState.threadsHydrated, + }); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("useAutoDeleteMergedThreads", () => { + it("starts a countdown toast and lets the user cancel before deletion", async () => { + statusQueriesResult = [ + { data: { pr: { state: "merged" } } }, + { data: { pr: { state: "open" } } }, + ]; + + await mountHook(makeSettings()); + + expect(toastCalls).toHaveLength(1); + expect(toastCalls[0]).toMatchObject({ + type: "info", + title: 'PR merged – "Merged worktree" will be deleted', + }); + + await act(async () => { + toastCalls[0]?.actionProps?.onClick(); + await flushAsyncWork(); + }); + + expect(toastCalls.at(-1)).toMatchObject({ + type: "success", + title: "Auto-delete cancelled", + }); + + await advanceTime(60_000); + + expect(dispatchCommandMock).not.toHaveBeenCalled(); + expect(closeTerminalMock).not.toHaveBeenCalled(); + expect(removeWorktreeMock).not.toHaveBeenCalled(); + expect(invalidateQueriesMock).not.toHaveBeenCalled(); + }); + + it("auto-deletes merged threads, removes orphaned worktrees, and invalidates git queries", async () => { + statusQueriesResult = [ + { data: { pr: { state: "merged" } } }, + { data: { pr: { state: "open" } } }, + ]; + + await mountHook(makeSettings()); + await advanceTime(60_000); + + expect(dispatchCommandMock).toHaveBeenCalledTimes(2); + expect(dispatchCommandMock.mock.calls[0]?.[0]).toMatchObject({ + type: "thread.session.stop", + threadId, + }); + expect(dispatchCommandMock.mock.calls[1]?.[0]).toMatchObject({ + type: "thread.delete", + threadId, + }); + expect(closeTerminalMock).toHaveBeenCalledWith({ + threadId, + deleteHistory: true, + }); + expect(removeWorktreeMock).toHaveBeenCalledWith({ + cwd: "/workspace", + path: "/workspace/.okcode/thread-1", + force: true, + }); + expect(invalidateQueriesMock).toHaveBeenCalledWith({ queryKey: gitQueryKeys.all }); + expect(toastCalls.at(-1)).toMatchObject({ + type: "success", + title: "Merged thread deleted", + }); + }); + + it("clears pending timers when the feature is toggled off", async () => { + statusQueriesResult = [ + { data: { pr: { state: "merged" } } }, + { data: { pr: { state: "open" } } }, + ]; + + await mountHook(makeSettings()); + await updateHook(makeSettings({ autoDeleteMergedThreads: false })); + await advanceTime(60_000); + + expect(toastCloseMock).toHaveBeenCalledWith("toast-1"); + expect(dispatchCommandMock).not.toHaveBeenCalled(); + expect(closeTerminalMock).not.toHaveBeenCalled(); + expect(removeWorktreeMock).not.toHaveBeenCalled(); + }); + + it("skips auto-delete when the merged thread still shares its worktree", async () => { + seedStore({ sharedWorktreePath: "/workspace/.okcode/thread-1" }); + vi.clearAllTimers(); + statusQueriesResult = [ + { data: { pr: { state: "merged" } } }, + { data: { pr: { state: "open" } } }, + ]; + + await mountHook(makeSettings()); + await advanceTime(60_000); + + expect(toastCalls).toHaveLength(0); + expect(dispatchCommandMock).not.toHaveBeenCalled(); + expect(removeWorktreeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/useConnectionHealth.test.tsx b/apps/web/src/hooks/useConnectionHealth.test.tsx new file mode 100644 index 000000000..d282b3d12 --- /dev/null +++ b/apps/web/src/hooks/useConnectionHealth.test.tsx @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; + +import type { ConnectionMetrics, TransportState } from "../wsTransport"; +import { type ConnectionHealth, useConnectionHealth } from "./useConnectionHealth"; + +const { createWsNativeApiMock, getTransportMetricsMock, onTransportStateChangeMock } = vi.hoisted( + () => ({ + createWsNativeApiMock: vi.fn(), + getTransportMetricsMock: vi.fn(), + onTransportStateChangeMock: vi.fn(), + }), +); + +vi.mock("../wsNativeApi", () => ({ + createWsNativeApi: createWsNativeApiMock, + getTransportMetrics: getTransportMetricsMock, + onTransportStateChange: onTransportStateChangeMock, +})); + +const DEFAULT_METRICS: ConnectionMetrics = { + reconnectCount: 0, + lastConnectedAt: null, + lastDisconnectedAt: null, + latencyMs: null, + uptimeMs: 0, +}; + +const transportStateListeners = new Set<(state: TransportState) => void>(); + +let currentState: TransportState = "connecting"; +let currentMetrics: ConnectionMetrics | null = DEFAULT_METRICS; +let renderer: ReactTestRenderer | null = null; +let latestHealth: ConnectionHealth | null = null; +let consoleErrorSpy: ReturnType | null = null; +const originalConsoleError = console.error; + +function HookHarness() { + latestHealth = useConnectionHealth(); + return null; +} + +function emitTransportState(nextState: TransportState) { + currentState = nextState; + for (const listener of new Set(transportStateListeners)) { + listener(nextState); + } +} + +async function mountHook() { + await act(async () => { + renderer = create(); + }); +} + +async function unmountHook() { + if (!renderer) { + return; + } + + await act(async () => { + renderer?.unmount(); + }); + renderer = null; +} + +async function advanceTime(ms: number) { + await act(async () => { + vi.advanceTimersByTime(ms); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation((message, ...args) => { + if (typeof message === "string" && message.includes("react-test-renderer is deprecated")) { + return; + } + originalConsoleError.call(console, message, ...args); + }); + + currentState = "connecting"; + currentMetrics = { ...DEFAULT_METRICS }; + latestHealth = null; + renderer = null; + transportStateListeners.clear(); + + createWsNativeApiMock.mockReset(); + getTransportMetricsMock.mockReset().mockImplementation(() => currentMetrics); + onTransportStateChangeMock.mockReset().mockImplementation((listener) => { + transportStateListeners.add(listener); + listener(currentState); + return () => { + transportStateListeners.delete(listener); + }; + }); +}); + +afterEach(async () => { + await unmountHook(); + consoleErrorSpy?.mockRestore(); + consoleErrorSpy = null; + transportStateListeners.clear(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("useConnectionHealth", () => { + it("refreshes metrics immediately on state changes and every five seconds", async () => { + const initialMetrics: ConnectionMetrics = { + reconnectCount: 0, + lastConnectedAt: null, + lastDisconnectedAt: null, + latencyMs: 42, + uptimeMs: 2_000, + }; + currentMetrics = initialMetrics; + + await mountHook(); + + expect(createWsNativeApiMock.mock.calls.length).toBeGreaterThanOrEqual(1); + expect(latestHealth).toEqual({ + state: "connecting", + isConnected: false, + isReconnecting: false, + metrics: initialMetrics, + }); + + const refreshedMetrics: ConnectionMetrics = { + reconnectCount: 1, + lastConnectedAt: Date.parse("2026-04-09T20:00:00.000Z"), + lastDisconnectedAt: Date.parse("2026-04-09T19:59:00.000Z"), + latencyMs: 9, + uptimeMs: 8_000, + }; + currentMetrics = refreshedMetrics; + + await act(async () => { + emitTransportState("open"); + }); + + expect(latestHealth).toEqual({ + state: "open", + isConnected: true, + isReconnecting: false, + metrics: refreshedMetrics, + }); + + const polledMetrics: ConnectionMetrics = { + reconnectCount: 2, + lastConnectedAt: Date.parse("2026-04-09T20:05:00.000Z"), + lastDisconnectedAt: Date.parse("2026-04-09T20:04:30.000Z"), + latencyMs: 4, + uptimeMs: 13_000, + }; + currentMetrics = polledMetrics; + + await advanceTime(4_999); + expect(latestHealth?.metrics).toEqual(refreshedMetrics); + + await advanceTime(1); + expect(latestHealth?.metrics).toEqual(polledMetrics); + }); + + it("keeps default metrics until snapshots become available and tracks reconnecting state", async () => { + currentMetrics = null; + + await mountHook(); + + expect(latestHealth).toEqual({ + state: "connecting", + isConnected: false, + isReconnecting: false, + metrics: DEFAULT_METRICS, + }); + + const reconnectingMetrics: ConnectionMetrics = { + reconnectCount: 3, + lastConnectedAt: Date.parse("2026-04-09T20:10:00.000Z"), + lastDisconnectedAt: Date.parse("2026-04-09T20:10:05.000Z"), + latencyMs: null, + uptimeMs: 0, + }; + currentMetrics = reconnectingMetrics; + + await act(async () => { + emitTransportState("reconnecting"); + }); + + expect(latestHealth).toEqual({ + state: "reconnecting", + isConnected: false, + isReconnecting: true, + metrics: reconnectingMetrics, + }); + }); +}); diff --git a/apps/web/src/lib/connectionSync.test.ts b/apps/web/src/lib/connectionSync.test.ts new file mode 100644 index 000000000..bc0648619 --- /dev/null +++ b/apps/web/src/lib/connectionSync.test.ts @@ -0,0 +1,122 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createConnectionSyncManager } from "./connectionSync"; +import { gitQueryKeys } from "./gitReactQuery"; +import { projectQueryKeys } from "./projectReactQuery"; +import { prReviewQueryKeys } from "./prReviewReactQuery"; +import { providerQueryKeys } from "./providerReactQuery"; +import { serverQueryKeys } from "./serverReactQuery"; +import { skillQueryKeys } from "./skillReactQuery"; +import type { WsTransport } from "../wsTransport"; + +function createTransportHarness() { + let reconnectListener: (() => void) | null = null; + + return { + transport: { + onReconnected: (listener: () => void) => { + reconnectListener = listener; + return () => { + if (reconnectListener === listener) { + reconnectListener = null; + } + }; + }, + } as unknown as WsTransport, + reconnect: () => { + reconnectListener?.(); + }, + }; +} + +function createQueryClientHarness() { + const invalidateQueries = vi.fn((_input: unknown) => Promise.resolve()); + return { + invalidateQueries, + queryClient: { + invalidateQueries, + } as unknown as QueryClient, + }; +} + +describe("createConnectionSyncManager", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("invalidates each query root on reconnect and calls onResync afterward", () => { + const transport = createTransportHarness(); + const { invalidateQueries, queryClient } = createQueryClientHarness(); + const callOrder: string[] = []; + invalidateQueries.mockImplementation((input: unknown) => { + const queryKey = (input as { queryKey: readonly string[] }).queryKey; + callOrder.push(queryKey[0] ?? ""); + return Promise.resolve(); + }); + const onResync = vi.fn(() => { + callOrder.push("resync"); + }); + + createConnectionSyncManager({ + transport: transport.transport, + queryClient, + onResync, + }); + + transport.reconnect(); + + expect(invalidateQueries.mock.calls.map(([input]) => input)).toEqual([ + { queryKey: gitQueryKeys.all }, + { queryKey: providerQueryKeys.all }, + { queryKey: projectQueryKeys.all }, + { queryKey: serverQueryKeys.all }, + { queryKey: prReviewQueryKeys.all }, + { queryKey: skillQueryKeys.all }, + ]); + expect(onResync).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual([ + gitQueryKeys.all[0], + providerQueryKeys.all[0], + projectQueryKeys.all[0], + serverQueryKeys.all[0], + prReviewQueryKeys.all[0], + skillQueryKeys.all[0], + "resync", + ]); + }); + + it("still invalidates query roots when onResync is omitted", () => { + const transport = createTransportHarness(); + const { invalidateQueries, queryClient } = createQueryClientHarness(); + + createConnectionSyncManager({ + transport: transport.transport, + queryClient, + }); + + transport.reconnect(); + + expect(invalidateQueries).toHaveBeenCalledTimes(6); + }); + + it("stops reacting to reconnects after unsubscribe", () => { + const transport = createTransportHarness(); + const { invalidateQueries, queryClient } = createQueryClientHarness(); + + const unsubscribe = createConnectionSyncManager({ + transport: transport.transport, + queryClient, + onResync: vi.fn(), + }); + + unsubscribe(); + transport.reconnect(); + + expect(invalidateQueries).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/nativeApi.test.ts b/apps/web/src/nativeApi.test.ts new file mode 100644 index 000000000..ee031715f --- /dev/null +++ b/apps/web/src/nativeApi.test.ts @@ -0,0 +1,100 @@ +import type { NativeApi } from "@okcode/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { createWsNativeApiMock, hasRuntimeConnectionTargetMock } = vi.hoisted(() => ({ + createWsNativeApiMock: vi.fn<() => NativeApi>(), + hasRuntimeConnectionTargetMock: vi.fn<() => boolean>(), +})); + +vi.mock("./lib/runtimeBridge", () => ({ + hasRuntimeConnectionTarget: hasRuntimeConnectionTargetMock, +})); + +vi.mock("./wsNativeApi", () => ({ + createWsNativeApi: createWsNativeApiMock, +})); + +function createTestApi(label: string): NativeApi { + return { label } as unknown as NativeApi; +} + +function stubWindow(value: { nativeApi?: NativeApi } = {}) { + vi.stubGlobal("window", value as Window & typeof globalThis); +} + +describe("nativeApi", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + createWsNativeApiMock.mockReset(); + hasRuntimeConnectionTargetMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("returns undefined when window is unavailable", async () => { + const { readNativeApi } = await import("./nativeApi"); + + expect(readNativeApi()).toBeUndefined(); + expect(hasRuntimeConnectionTargetMock).not.toHaveBeenCalled(); + expect(createWsNativeApiMock).not.toHaveBeenCalled(); + }); + + it("prefers window.nativeApi over the websocket-backed native api", async () => { + const windowApi = createTestApi("window"); + stubWindow({ nativeApi: windowApi }); + hasRuntimeConnectionTargetMock.mockReturnValue(true); + + const { readNativeApi } = await import("./nativeApi"); + + expect(readNativeApi()).toBe(windowApi); + expect(hasRuntimeConnectionTargetMock).not.toHaveBeenCalled(); + expect(createWsNativeApiMock).not.toHaveBeenCalled(); + }); + + it("creates and caches the websocket-backed api when a runtime target is available", async () => { + const wsApi = createTestApi("ws"); + stubWindow(); + hasRuntimeConnectionTargetMock.mockReturnValue(true); + createWsNativeApiMock.mockReturnValue(wsApi); + + const { readNativeApi } = await import("./nativeApi"); + + expect(readNativeApi()).toBe(wsApi); + expect(readNativeApi()).toBe(wsApi); + expect(hasRuntimeConnectionTargetMock).toHaveBeenCalledTimes(1); + expect(createWsNativeApiMock).toHaveBeenCalledTimes(1); + }); + + it("returns undefined when no runtime connection target is available", async () => { + stubWindow(); + hasRuntimeConnectionTargetMock.mockReturnValue(false); + + const { readNativeApi } = await import("./nativeApi"); + + expect(readNativeApi()).toBeUndefined(); + expect(hasRuntimeConnectionTargetMock).toHaveBeenCalledTimes(1); + expect(createWsNativeApiMock).not.toHaveBeenCalled(); + }); + + it("ensureNativeApi returns the resolved api", async () => { + const windowApi = createTestApi("window"); + stubWindow({ nativeApi: windowApi }); + + const { ensureNativeApi } = await import("./nativeApi"); + + expect(ensureNativeApi()).toBe(windowApi); + }); + + it("ensureNativeApi throws when no native api can be resolved", async () => { + stubWindow(); + hasRuntimeConnectionTargetMock.mockReturnValue(false); + + const { ensureNativeApi } = await import("./nativeApi"); + + expect(() => ensureNativeApi()).toThrowError("Native API not found"); + }); +}); diff --git a/bun.lock b/bun.lock index effca71bb..efd5be20e 100644 --- a/bun.lock +++ b/bun.lock @@ -201,11 +201,13 @@ "@types/babel__core": "^7.20.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/react-test-renderer": "^19.0.0", "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", "msw": "2.12.11", "playwright": "^1.58.2", + "react-test-renderer": "^19.0.0", "tailwindcss": "^4.0.0", "typescript": "catalog:", "vite": "^8.0.0", @@ -1146,6 +1148,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/react-test-renderer": ["@types/react-test-renderer@19.1.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ=="], + "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], "@types/slice-ansi": ["@types/slice-ansi@4.0.0", "", {}, "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ=="], @@ -2020,6 +2024,8 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-test-renderer": ["react-test-renderer@19.2.5", "", { "dependencies": { "react-is": "^19.2.5", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-kwViRpdISMTpcpy5B6TSewfJzRjnajihRaj57ZmOWKD+SPN6k9LUM13O0pfOuW8ir6B6OOiAXwCRqOoVxRNykA=="], + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -2472,6 +2478,8 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-test-renderer/react-is": ["react-is@19.2.5", "", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="], + "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],