diff --git a/src/browser/api.test.ts b/src/browser/api.test.ts new file mode 100644 index 000000000..12503230b --- /dev/null +++ b/src/browser/api.test.ts @@ -0,0 +1,135 @@ +/** + * Tests for browser API client + * Tests the invokeIPC function to ensure it behaves consistently with Electron's ipcRenderer.invoke() + */ + +import { describe, test, expect } from "bun:test"; + +// Helper to create a mock fetch that returns a specific response +function createMockFetch(responseData: unknown) { + return () => { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(responseData), + } as Response); + }; +} + +interface InvokeResponse { + success: boolean; + data?: T; + error?: unknown; +} + +// Helper to create invokeIPC function with mocked fetch +function createInvokeIPC( + mockFetch: (url: string, init?: RequestInit) => Promise +): (channel: string, ...args: unknown[]) => Promise { + const API_BASE = "http://localhost:3000"; + + async function invokeIPC(channel: string, ...args: unknown[]): Promise { + const response = await mockFetch(`${API_BASE}/ipc/${encodeURIComponent(channel)}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ args }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = (await response.json()) as InvokeResponse; + + // Return the result as-is - let the caller handle success/failure + // This matches the behavior of Electron's ipcRenderer.invoke() which doesn't throw on error + if (!result.success) { + return result as T; + } + + // Success - unwrap and return the data + return result.data as T; + } + + return invokeIPC; +} + +describe("Browser API invokeIPC", () => { + test("should return error object on failure (matches Electron behavior)", async () => { + const mockFetch = createMockFetch({ + success: false, + error: "fatal: contains modified or untracked files", + }); + + const invokeIPC = createInvokeIPC(mockFetch); + + // Fixed behavior: invokeIPC returns error object instead of throwing + // This matches Electron's ipcRenderer.invoke() which never throws on error + const result = await invokeIPC<{ success: boolean; error?: string }>( + "WORKSPACE_REMOVE", + "test-workspace", + { force: false } + ); + + expect(result).toEqual({ + success: false, + error: "fatal: contains modified or untracked files", + }); + }); + + test("should return success data on success", async () => { + const mockFetch = createMockFetch({ + success: true, + data: { someData: "value" }, + }); + + const invokeIPC = createInvokeIPC(mockFetch); + + const result = await invokeIPC("WORKSPACE_REMOVE", "test-workspace", { force: true }); + + expect(result).toEqual({ someData: "value" }); + }); + + test("should throw on HTTP errors", async () => { + const mockFetch = () => { + return Promise.resolve({ + ok: false, + status: 500, + } as Response); + }; + + const invokeIPC = createInvokeIPC(mockFetch); + + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(invokeIPC("WORKSPACE_REMOVE", "test-workspace", { force: false })).rejects.toThrow( + "HTTP error! status: 500" + ); + }); + + test("should return structured error objects as-is", async () => { + const structuredError = { + type: "STREAMING_IN_PROGRESS", + message: "Cannot send message while streaming", + workspaceId: "test-workspace", + }; + + const mockFetch = createMockFetch({ + success: false, + error: structuredError, + }); + + const invokeIPC = createInvokeIPC(mockFetch); + + const result = await invokeIPC("WORKSPACE_SEND_MESSAGE", "test-workspace", { + role: "user", + content: [{ type: "text", text: "test" }], + }); + + // Structured errors should be returned as-is + expect(result).toEqual({ + success: false, + error: structuredError, + }); + }); +}); diff --git a/src/browser/api.ts b/src/browser/api.ts index 54fc8748c..4be41e43d 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -29,14 +29,10 @@ async function invokeIPC(channel: string, ...args: unknown[]): Promise { const result = (await response.json()) as InvokeResponse; + // Return the result as-is - let the caller handle success/failure + // This matches the behavior of Electron's ipcRenderer.invoke() which doesn't throw on error if (!result.success) { - // Failed response - check if it's a structured error or simple string - if (typeof result.error === "object" && result.error !== null) { - // Structured error (e.g., SendMessageError) - return as Result for caller to handle - return result as T; - } - // Simple string error - throw it - throw new Error(typeof result.error === "string" ? result.error : "Unknown error"); + return result as T; } // Success - unwrap and return the data diff --git a/src/hooks/useAutoCompactContinue.ts b/src/hooks/useAutoCompactContinue.ts index bb23f0406..b23533792 100644 --- a/src/hooks/useAutoCompactContinue.ts +++ b/src/hooks/useAutoCompactContinue.ts @@ -81,11 +81,25 @@ export function useAutoCompactContinue() { // Build options and send message directly const options = buildSendMessageOptions(workspaceId); - window.api.workspace.sendMessage(workspaceId, continueMessage, options).catch((error) => { - console.error("Failed to send continue message:", error); - // If sending failed, remove from processed set to allow retry - processedMessageIds.current.delete(idForGuard); - }); + void (async () => { + try { + const result = await window.api.workspace.sendMessage( + workspaceId, + continueMessage, + options + ); + // Check if send failed (browser API returns error object, not throw) + if (!result.success && "error" in result) { + console.error("Failed to send continue message:", result.error); + // If sending failed, remove from processed set to allow retry + processedMessageIds.current.delete(idForGuard); + } + } catch (error) { + // Handle network/parsing errors (HTTP errors, etc.) + console.error("Failed to send continue message:", error); + processedMessageIds.current.delete(idForGuard); + } + })(); } }; diff --git a/src/hooks/useWorkspaceManagement.ts b/src/hooks/useWorkspaceManagement.ts index 3f54a52b0..5b3e5bb51 100644 --- a/src/hooks/useWorkspaceManagement.ts +++ b/src/hooks/useWorkspaceManagement.ts @@ -117,27 +117,33 @@ export function useWorkspaceManagement({ workspaceId: string, options?: { force?: boolean } ): Promise<{ success: boolean; error?: string }> => { - const result = await window.api.workspace.remove(workspaceId, options); - if (result.success) { - // Clean up workspace-specific localStorage keys - deleteWorkspaceStorage(workspaceId); - - // Backend has already updated the config - reload projects to get updated state - const projectsList = await window.api.projects.list(); - const loadedProjects = new Map(projectsList); - onProjectsUpdate(loadedProjects); - - // Reload workspace metadata - await loadWorkspaceMetadata(); - - // Clear selected workspace if it was removed - if (selectedWorkspace?.workspaceId === workspaceId) { - onSelectedWorkspaceUpdate(null); + try { + const result = await window.api.workspace.remove(workspaceId, options); + if (result.success) { + // Clean up workspace-specific localStorage keys + deleteWorkspaceStorage(workspaceId); + + // Backend has already updated the config - reload projects to get updated state + const projectsList = await window.api.projects.list(); + const loadedProjects = new Map(projectsList); + onProjectsUpdate(loadedProjects); + + // Reload workspace metadata + await loadWorkspaceMetadata(); + + // Clear selected workspace if it was removed + if (selectedWorkspace?.workspaceId === workspaceId) { + onSelectedWorkspaceUpdate(null); + } + return { success: true }; + } else { + console.error("Failed to remove workspace:", result.error); + return { success: false, error: result.error }; } - return { success: true }; - } else { - console.error("Failed to remove workspace:", result.error); - return { success: false, error: result.error }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("Failed to remove workspace:", errorMessage); + return { success: false, error: errorMessage }; } }, [loadWorkspaceMetadata, onProjectsUpdate, onSelectedWorkspaceUpdate, selectedWorkspace] @@ -145,35 +151,41 @@ export function useWorkspaceManagement({ const renameWorkspace = useCallback( async (workspaceId: string, newName: string): Promise<{ success: boolean; error?: string }> => { - const result = await window.api.workspace.rename(workspaceId, newName); - if (result.success) { - // Backend has already updated the config - reload projects to get updated state - const projectsList = await window.api.projects.list(); - const loadedProjects = new Map(projectsList); - onProjectsUpdate(loadedProjects); - - // Reload workspace metadata - await loadWorkspaceMetadata(); - - // Update selected workspace if it was renamed - if (selectedWorkspace?.workspaceId === workspaceId) { - const newWorkspaceId = result.data.newWorkspaceId; - - // Get updated workspace metadata from backend - const newMetadata = await window.api.workspace.getInfo(newWorkspaceId); - if (newMetadata) { - onSelectedWorkspaceUpdate({ - projectPath: selectedWorkspace.projectPath, - projectName: newMetadata.projectName, - namedWorkspacePath: newMetadata.namedWorkspacePath, - workspaceId: newWorkspaceId, - }); + try { + const result = await window.api.workspace.rename(workspaceId, newName); + if (result.success) { + // Backend has already updated the config - reload projects to get updated state + const projectsList = await window.api.projects.list(); + const loadedProjects = new Map(projectsList); + onProjectsUpdate(loadedProjects); + + // Reload workspace metadata + await loadWorkspaceMetadata(); + + // Update selected workspace if it was renamed + if (selectedWorkspace?.workspaceId === workspaceId) { + const newWorkspaceId = result.data.newWorkspaceId; + + // Get updated workspace metadata from backend + const newMetadata = await window.api.workspace.getInfo(newWorkspaceId); + if (newMetadata) { + onSelectedWorkspaceUpdate({ + projectPath: selectedWorkspace.projectPath, + projectName: newMetadata.projectName, + namedWorkspacePath: newMetadata.namedWorkspacePath, + workspaceId: newWorkspaceId, + }); + } } + return { success: true }; + } else { + console.error("Failed to rename workspace:", result.error); + return { success: false, error: result.error }; } - return { success: true }; - } else { - console.error("Failed to rename workspace:", result.error); - return { success: false, error: result.error }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("Failed to rename workspace:", errorMessage); + return { success: false, error: errorMessage }; } }, [loadWorkspaceMetadata, onProjectsUpdate, onSelectedWorkspaceUpdate, selectedWorkspace]