diff --git a/apps/array/src/main/services/updates/schemas.ts b/apps/array/src/main/services/updates/schemas.ts index c872a56f..27ab1310 100644 --- a/apps/array/src/main/services/updates/schemas.ts +++ b/apps/array/src/main/services/updates/schemas.ts @@ -28,6 +28,7 @@ export type UpdatesStatusPayload = { checking: boolean; upToDate?: boolean; version?: string; + error?: string; }; export interface UpdatesEvents { diff --git a/apps/array/src/main/services/updates/service.test.ts b/apps/array/src/main/services/updates/service.test.ts new file mode 100644 index 00000000..e3d0244a --- /dev/null +++ b/apps/array/src/main/services/updates/service.test.ts @@ -0,0 +1,615 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { UpdatesEvent } from "./schemas.js"; + +// Use vi.hoisted to ensure mocks are available when vi.mock is hoisted +const { mockApp, mockAutoUpdater } = vi.hoisted(() => ({ + mockAutoUpdater: { + setFeedURL: vi.fn(), + checkForUpdates: vi.fn(), + quitAndInstall: vi.fn(), + on: vi.fn(), + }, + mockApp: { + isPackaged: true, + getVersion: vi.fn(() => "1.0.0"), + on: vi.fn(), + whenReady: vi.fn(() => Promise.resolve()), + }, +})); + +vi.mock("electron", () => ({ + app: mockApp, + autoUpdater: mockAutoUpdater, +})); + +vi.mock("../../lib/logger.js", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +// Import the service after mocks are set up +import { UpdatesService } from "./service.js"; + +// Helper to initialize service and wait for setup without running the periodic interval infinitely +async function initializeService(service: UpdatesService): Promise { + service.init(); + // Allow the whenReady promise microtask to resolve + await vi.advanceTimersByTimeAsync(0); +} + +describe("UpdatesService", () => { + let service: UpdatesService; + let originalPlatform: PropertyDescriptor | undefined; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + // Store original values + originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + originalEnv = { ...process.env }; + + // Reset mocks to default state + mockApp.isPackaged = true; + mockApp.getVersion.mockReturnValue("1.0.0"); + mockApp.on.mockClear(); + mockApp.whenReady.mockResolvedValue(undefined); + + // Set default platform to darwin (macOS) + Object.defineProperty(process, "platform", { + value: "darwin", + configurable: true, + }); + + // Clear env flag + delete process.env.ELECTRON_DISABLE_AUTO_UPDATE; + + service = new UpdatesService(); + }); + + afterEach(() => { + vi.useRealTimers(); + + // Restore original values + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform); + } + process.env = originalEnv; + }); + + describe("isEnabled", () => { + it("returns true when app is packaged on macOS", () => { + mockApp.isPackaged = true; + Object.defineProperty(process, "platform", { + value: "darwin", + configurable: true, + }); + + const newService = new UpdatesService(); + expect(newService.isEnabled).toBe(true); + }); + + it("returns true when app is packaged on Windows", () => { + mockApp.isPackaged = true; + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + + const newService = new UpdatesService(); + expect(newService.isEnabled).toBe(true); + }); + + it("returns false when app is not packaged", () => { + mockApp.isPackaged = false; + + const newService = new UpdatesService(); + expect(newService.isEnabled).toBe(false); + }); + + it("returns false when ELECTRON_DISABLE_AUTO_UPDATE is set", () => { + mockApp.isPackaged = true; + process.env.ELECTRON_DISABLE_AUTO_UPDATE = "1"; + + const newService = new UpdatesService(); + expect(newService.isEnabled).toBe(false); + }); + + it("returns false on Linux", () => { + mockApp.isPackaged = true; + Object.defineProperty(process, "platform", { + value: "linux", + configurable: true, + }); + + const newService = new UpdatesService(); + expect(newService.isEnabled).toBe(false); + }); + + it("returns false on unsupported platforms", () => { + mockApp.isPackaged = true; + Object.defineProperty(process, "platform", { + value: "freebsd", + configurable: true, + }); + + const newService = new UpdatesService(); + expect(newService.isEnabled).toBe(false); + }); + }); + + describe("init", () => { + it("sets up auto updater when enabled", async () => { + await initializeService(service); + + expect(mockApp.on).toHaveBeenCalledWith( + "browser-window-focus", + expect.any(Function), + ); + expect(mockApp.whenReady).toHaveBeenCalled(); + }); + + it("does not set up auto updater when disabled via env flag", () => { + process.env.ELECTRON_DISABLE_AUTO_UPDATE = "1"; + + const newService = new UpdatesService(); + newService.init(); + + expect(mockApp.whenReady).not.toHaveBeenCalled(); + }); + + it("does not set up auto updater on unsupported platform", () => { + Object.defineProperty(process, "platform", { + value: "linux", + configurable: true, + }); + + const newService = new UpdatesService(); + newService.init(); + + expect(mockApp.whenReady).not.toHaveBeenCalled(); + }); + + it("prevents multiple initializations", async () => { + await initializeService(service); + + const firstCallCount = mockAutoUpdater.setFeedURL.mock.calls.length; + + // Simulate whenReady resolving again (shouldn't happen, but testing guard) + await initializeService(service); + + // setFeedURL should not be called again + expect(mockAutoUpdater.setFeedURL.mock.calls.length).toBe(firstCallCount); + }); + }); + + describe("feedUrl", () => { + it("constructs correct feed URL with platform, arch, and version", async () => { + Object.defineProperty(process, "arch", { + value: "arm64", + configurable: true, + }); + mockApp.getVersion.mockReturnValue("2.0.0"); + + await initializeService(service); + + expect(mockAutoUpdater.setFeedURL).toHaveBeenCalledWith({ + url: "https://update.electronjs.org/PostHog/Array/darwin-arm64/2.0.0", + }); + }); + }); + + describe("checkForUpdates", () => { + it("returns success when updates are enabled", () => { + const result = service.checkForUpdates(); + expect(result).toEqual({ success: true }); + }); + + it("returns error when updates are disabled (not packaged)", () => { + mockApp.isPackaged = false; + + const newService = new UpdatesService(); + const result = newService.checkForUpdates(); + + expect(result).toEqual({ + success: false, + error: "Updates only available in packaged builds", + }); + }); + + it("returns error when updates are disabled (unsupported platform)", () => { + Object.defineProperty(process, "platform", { + value: "linux", + configurable: true, + }); + + const newService = new UpdatesService(); + const result = newService.checkForUpdates(); + + expect(result).toEqual({ + success: false, + error: "Auto updates only supported on macOS and Windows", + }); + }); + + it("returns error when already checking for updates", () => { + // First call starts the check + service.checkForUpdates(); + + // Second call should fail + const result = service.checkForUpdates(); + expect(result).toEqual({ + success: false, + error: "Already checking for updates", + }); + }); + + it("emits status event when checking starts", () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + service.checkForUpdates(); + + expect(statusHandler).toHaveBeenCalledWith({ checking: true }); + }); + + it("calls autoUpdater.checkForUpdates", async () => { + await initializeService(service); + + mockAutoUpdater.checkForUpdates.mockClear(); + service.checkForUpdates(); + + expect(mockAutoUpdater.checkForUpdates).toHaveBeenCalled(); + }); + + it("allows retry after previous check completes", async () => { + await initializeService(service); + + // First check + const result1 = service.checkForUpdates(); + expect(result1.success).toBe(true); + + // Simulate completion + const notAvailableHandler = mockAutoUpdater.on.mock.calls.find( + ([event]) => event === "update-not-available", + )?.[1]; + + if (notAvailableHandler) { + notAvailableHandler(); + } + + // Second check should succeed + const result2 = service.checkForUpdates(); + expect(result2.success).toBe(true); + }); + }); + + describe("installUpdate", () => { + it("returns false when no update is ready", () => { + const result = service.installUpdate(); + expect(result).toEqual({ installed: false }); + }); + + it("calls quitAndInstall when update is ready", async () => { + await initializeService(service); + + // Simulate update downloaded + const updateDownloadedHandler = mockAutoUpdater.on.mock.calls.find( + ([event]) => event === "update-downloaded", + )?.[1]; + + if (updateDownloadedHandler) { + updateDownloadedHandler({}, "Release notes", "v2.0.0"); + } + + const result = service.installUpdate(); + expect(result).toEqual({ installed: true }); + expect(mockAutoUpdater.quitAndInstall).toHaveBeenCalled(); + }); + + it("returns false if quitAndInstall throws", async () => { + await initializeService(service); + + // Simulate update downloaded + const updateDownloadedHandler = mockAutoUpdater.on.mock.calls.find( + ([event]) => event === "update-downloaded", + )?.[1]; + + if (updateDownloadedHandler) { + updateDownloadedHandler({}, "Release notes", "v2.0.0"); + } + + mockAutoUpdater.quitAndInstall.mockImplementation(() => { + throw new Error("Failed to install"); + }); + + const result = service.installUpdate(); + expect(result).toEqual({ installed: false }); + }); + }); + + describe("triggerMenuCheck", () => { + it("emits CheckFromMenu event", () => { + const handler = vi.fn(); + service.on(UpdatesEvent.CheckFromMenu, handler); + + service.triggerMenuCheck(); + + expect(handler).toHaveBeenCalledWith(true); + }); + }); + + describe("autoUpdater event handling", () => { + beforeEach(async () => { + await initializeService(service); + }); + + it("registers all required event handlers", () => { + const registeredEvents = mockAutoUpdater.on.mock.calls.map( + ([event]) => event, + ); + + expect(registeredEvents).toContain("error"); + expect(registeredEvents).toContain("checking-for-update"); + expect(registeredEvents).toContain("update-available"); + expect(registeredEvents).toContain("update-not-available"); + expect(registeredEvents).toContain("update-downloaded"); + }); + + it("handles update-not-available event", () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + // Start a check + service.checkForUpdates(); + statusHandler.mockClear(); + + // Simulate no update available + const notAvailableHandler = mockAutoUpdater.on.mock.calls.find( + ([event]) => event === "update-not-available", + )?.[1]; + + if (notAvailableHandler) { + notAvailableHandler(); + } + + expect(statusHandler).toHaveBeenCalledWith({ + checking: false, + upToDate: true, + version: "1.0.0", + }); + }); + + it("handles update-downloaded event with version info", () => { + const readyHandler = vi.fn(); + service.on(UpdatesEvent.Ready, readyHandler); + + // Simulate update downloaded with version + const downloadedHandler = mockAutoUpdater.on.mock.calls.find( + ([event]) => event === "update-downloaded", + )?.[1]; + + if (downloadedHandler) { + downloadedHandler({}, "Release notes here", "v2.0.0"); + } + + expect(readyHandler).toHaveBeenCalledWith(true); + }); + + it("handles error event and emits status with error", () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + // Start a check + service.checkForUpdates(); + statusHandler.mockClear(); + + // Simulate error + const errorHandler = mockAutoUpdater.on.mock.calls.find( + ([event]) => event === "error", + )?.[1]; + + if (errorHandler) { + errorHandler(new Error("Network error")); + } + + expect(statusHandler).toHaveBeenCalledWith({ + checking: false, + error: "Network error", + }); + }); + + it("handles error event gracefully when not checking", () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + // Simulate error without starting a check + const errorHandler = mockAutoUpdater.on.mock.calls.find( + ([event]) => event === "error", + )?.[1]; + + expect(() => { + if (errorHandler) { + errorHandler(new Error("Test error")); + } + }).not.toThrow(); + + // Should not emit status since we weren't checking + expect(statusHandler).not.toHaveBeenCalled(); + }); + }); + + describe("check timeout", () => { + beforeEach(async () => { + await initializeService(service); + }); + + it("times out after 60 seconds if no response", async () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + service.checkForUpdates(); + statusHandler.mockClear(); + + // Advance 60 seconds + await vi.advanceTimersByTimeAsync(60 * 1000); + + expect(statusHandler).toHaveBeenCalledWith({ + checking: false, + error: "Update check timed out. Please try again.", + }); + }); + + it("clears timeout when update-not-available fires", async () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + service.checkForUpdates(); + statusHandler.mockClear(); + + // Simulate response before timeout + const notAvailableHandler = mockAutoUpdater.on.mock.calls.find( + ([event]) => event === "update-not-available", + )?.[1]; + + if (notAvailableHandler) { + notAvailableHandler(); + } + + // Advance past the timeout + await vi.advanceTimersByTimeAsync(60 * 1000); + + // Should only have received the upToDate status, not a timeout + expect(statusHandler).toHaveBeenCalledTimes(1); + expect(statusHandler).toHaveBeenCalledWith({ + checking: false, + upToDate: true, + version: "1.0.0", + }); + }); + + it("clears timeout when error fires", async () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + service.checkForUpdates(); + statusHandler.mockClear(); + + // Simulate error before timeout + const errorHandler = mockAutoUpdater.on.mock.calls.find( + ([event]) => event === "error", + )?.[1]; + + if (errorHandler) { + errorHandler(new Error("Network error")); + } + + // Advance past the timeout + await vi.advanceTimersByTimeAsync(60 * 1000); + + // Should only have received the error status, not a timeout + expect(statusHandler).toHaveBeenCalledTimes(1); + expect(statusHandler).toHaveBeenCalledWith({ + checking: false, + error: "Network error", + }); + }); + }); + + describe("flushPendingNotification", () => { + it("emits Ready event on window focus when update is pending", async () => { + await initializeService(service); + + const readyHandler = vi.fn(); + service.on(UpdatesEvent.Ready, readyHandler); + + // Simulate update downloaded + const downloadedHandler = mockAutoUpdater.on.mock.calls.find( + ([event]) => event === "update-downloaded", + )?.[1]; + + if (downloadedHandler) { + downloadedHandler({}, "Release notes", "v2.0.0"); + } + + // First Ready event from handleUpdateDownloaded + expect(readyHandler).toHaveBeenCalledTimes(1); + + // Get the browser-window-focus callback and call it + const focusCallback = mockApp.on.mock.calls.find( + ([event]) => event === "browser-window-focus", + )?.[1]; + + // Reset the handler count + readyHandler.mockClear(); + + // Pending notification should be false now, so no second emit + if (focusCallback) { + focusCallback(); + } + + expect(readyHandler).not.toHaveBeenCalled(); + }); + }); + + describe("periodic update checks", () => { + it("performs initial check on setup", async () => { + await initializeService(service); + + expect(mockAutoUpdater.checkForUpdates).toHaveBeenCalled(); + }); + + it("performs check every 6 hours", async () => { + await initializeService(service); + + const initialCallCount = + mockAutoUpdater.checkForUpdates.mock.calls.length; + + // Advance 6 hours + await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000); + + expect(mockAutoUpdater.checkForUpdates.mock.calls.length).toBe( + initialCallCount + 1, + ); + + // Advance another 6 hours + await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000); + + expect(mockAutoUpdater.checkForUpdates.mock.calls.length).toBe( + initialCallCount + 2, + ); + }); + }); + + describe("error handling", () => { + it("catches errors during checkForUpdates", async () => { + await initializeService(service); + + mockAutoUpdater.checkForUpdates.mockImplementation(() => { + throw new Error("Network error"); + }); + + // Should not throw + expect(() => service.checkForUpdates()).not.toThrow(); + }); + + it("handles setFeedURL failure gracefully", async () => { + mockAutoUpdater.setFeedURL.mockImplementation(() => { + throw new Error("Invalid URL"); + }); + + // Should not throw + expect(() => { + const newService = new UpdatesService(); + newService.init(); + }).not.toThrow(); + }); + }); +}); diff --git a/apps/array/src/main/services/updates/service.ts b/apps/array/src/main/services/updates/service.ts index bb0b9333..93bdd532 100644 --- a/apps/array/src/main/services/updates/service.ts +++ b/apps/array/src/main/services/updates/service.ts @@ -17,12 +17,16 @@ export class UpdatesService extends TypedEventEmitter { private static readonly REPO_OWNER = "PostHog"; private static readonly REPO_NAME = "Array"; private static readonly CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours + private static readonly CHECK_TIMEOUT_MS = 60 * 1000; // 1 minute timeout for checks private static readonly DISABLE_ENV_FLAG = "ELECTRON_DISABLE_AUTO_UPDATE"; private static readonly SUPPORTED_PLATFORMS = ["darwin", "win32"]; private updateReady = false; private pendingNotification = false; private checkingForUpdates = false; + private checkTimeoutId: ReturnType | null = null; + private downloadedVersion: string | null = null; + private initialized = false; get isEnabled(): boolean { return ( @@ -46,6 +50,8 @@ export class UpdatesService extends TypedEventEmitter { !UpdatesService.SUPPORTED_PLATFORMS.includes(process.platform) ) { log.info("Auto updates only supported on macOS and Windows"); + } else if (!app.isPackaged) { + log.info("Auto updates only available in packaged builds"); } return; } @@ -79,28 +85,92 @@ export class UpdatesService extends TypedEventEmitter { installUpdate(): InstallUpdateOutput { if (!this.updateReady) { + log.warn("installUpdate called but no update is ready"); + return { installed: false }; + } + + log.info("Installing update and restarting...", { + downloadedVersion: this.downloadedVersion, + }); + + try { + autoUpdater.quitAndInstall(); + return { installed: true }; + } catch (error) { + log.error("Failed to quit and install update", error); return { installed: false }; } - autoUpdater.quitAndInstall(); - return { installed: true }; } private setupAutoUpdater(): void { - autoUpdater.setFeedURL({ url: this.feedUrl }); + if (this.initialized) { + log.warn("setupAutoUpdater called multiple times, ignoring"); + return; + } - autoUpdater.on("error", (error) => log.error("Auto update error", error)); - autoUpdater.on("update-available", () => - log.info("Update available, downloading..."), - ); + this.initialized = true; + const feedUrl = this.feedUrl; + log.info("Setting up auto updater", { + feedUrl, + currentVersion: app.getVersion(), + platform: process.platform, + arch: process.arch, + }); + + try { + autoUpdater.setFeedURL({ url: feedUrl }); + } catch (error) { + log.error("Failed to set feed URL", error); + return; + } + + autoUpdater.on("error", (error) => this.handleError(error)); + autoUpdater.on("checking-for-update", () => this.handleCheckingForUpdate()); + autoUpdater.on("update-available", () => this.handleUpdateAvailable()); autoUpdater.on("update-not-available", () => this.handleNoUpdate()); - autoUpdater.on("update-downloaded", () => this.handleUpdateDownloaded()); + autoUpdater.on("update-downloaded", (_event, _releaseNotes, releaseName) => + this.handleUpdateDownloaded(releaseName), + ); + // Perform initial check this.performCheck(); + + // Set up periodic checks setInterval(() => this.performCheck(), UpdatesService.CHECK_INTERVAL_MS); } + private handleError(error: Error): void { + this.clearCheckTimeout(); + log.error("Auto update error", { + message: error.message, + stack: error.stack, + feedUrl: this.feedUrl, + }); + + // Reset checking state on error so user can retry + if (this.checkingForUpdates) { + this.checkingForUpdates = false; + this.emitStatus({ + checking: false, + error: error.message, + }); + } + } + + private handleCheckingForUpdate(): void { + log.info("Checking for updates..."); + } + + private handleUpdateAvailable(): void { + this.clearCheckTimeout(); + log.info("Update available, downloading..."); + // Keep checkingForUpdates true while downloading + // The download is now in progress + } + private handleNoUpdate(): void { - log.info("No updates available"); + this.clearCheckTimeout(); + log.info("No updates available", { currentVersion: app.getVersion() }); if (this.checkingForUpdates) { this.checkingForUpdates = false; this.emitStatus({ @@ -111,8 +181,16 @@ export class UpdatesService extends TypedEventEmitter { } } - private handleUpdateDownloaded(): void { - log.info("Update downloaded, awaiting user confirmation"); + private handleUpdateDownloaded(releaseName?: string): void { + this.clearCheckTimeout(); + this.checkingForUpdates = false; + this.downloadedVersion = releaseName ?? null; + + log.info("Update downloaded, awaiting user confirmation", { + currentVersion: app.getVersion(), + downloadedVersion: this.downloadedVersion, + }); + this.updateReady = true; this.pendingNotification = true; this.flushPendingNotification(); @@ -120,6 +198,9 @@ export class UpdatesService extends TypedEventEmitter { private flushPendingNotification(): void { if (this.updateReady && this.pendingNotification) { + log.info("Notifying user that update is ready", { + downloadedVersion: this.downloadedVersion, + }); this.emit(UpdatesEvent.Ready, true); this.pendingNotification = false; } @@ -129,15 +210,40 @@ export class UpdatesService extends TypedEventEmitter { checking: boolean; upToDate?: boolean; version?: string; + error?: string; }): void { this.emit(UpdatesEvent.Status, status); } private performCheck(): void { + // Clear any existing timeout + this.clearCheckTimeout(); + + // Set a timeout to reset the checking state if the check takes too long + this.checkTimeoutId = setTimeout(() => { + if (this.checkingForUpdates) { + log.warn("Update check timed out after 60 seconds"); + this.checkingForUpdates = false; + this.emitStatus({ + checking: false, + error: "Update check timed out. Please try again.", + }); + } + }, UpdatesService.CHECK_TIMEOUT_MS); + try { autoUpdater.checkForUpdates(); } catch (error) { + this.clearCheckTimeout(); log.error("Failed to check for updates", error); + this.checkingForUpdates = false; + } + } + + private clearCheckTimeout(): void { + if (this.checkTimeoutId) { + clearTimeout(this.checkTimeoutId); + this.checkTimeoutId = null; } } } diff --git a/apps/array/src/renderer/components/UpdatePrompt.tsx b/apps/array/src/renderer/components/UpdatePrompt.tsx index c8d29891..719d4837 100644 --- a/apps/array/src/renderer/components/UpdatePrompt.tsx +++ b/apps/array/src/renderer/components/UpdatePrompt.tsx @@ -32,7 +32,10 @@ export function UpdatePrompt() { trpcReact.updates.onStatus.useSubscription(undefined, { enabled: isEnabled, onData: (status) => { - if (status.checking === false && status.upToDate) { + if (status.checking === false && status.error) { + setCheckingForUpdates(false); + setCheckResultMessage(status.error); + } else if (status.checking === false && status.upToDate) { setCheckingForUpdates(false); const versionSuffix = status.version ? ` (v${status.version})` : ""; setCheckResultMessage(`Array is up to date${versionSuffix}`); diff --git a/apps/array/src/renderer/stores/navigationStore.test.ts b/apps/array/src/renderer/stores/navigationStore.test.ts index edc0c887..ef4d17a8 100644 --- a/apps/array/src/renderer/stores/navigationStore.test.ts +++ b/apps/array/src/renderer/stores/navigationStore.test.ts @@ -12,7 +12,9 @@ vi.mock("@features/task-detail/stores/taskExecutionStore", () => ({ }, })); vi.mock("@features/workspace/stores/workspaceStore", () => ({ - useWorkspaceStore: { getState: () => ({ ensureWorkspace: vi.fn() }) }, + useWorkspaceStore: { + getState: () => ({ ensureWorkspace: vi.fn(), workspaces: {} }), + }, })); vi.mock("@stores/registeredFoldersStore", () => ({ useRegisteredFoldersStore: { getState: () => ({ addFolder: vi.fn() }) }, diff --git a/apps/array/src/test/setup.ts b/apps/array/src/test/setup.ts index 94fb3196..dd470bfb 100644 --- a/apps/array/src/test/setup.ts +++ b/apps/array/src/test/setup.ts @@ -1,6 +1,36 @@ import "@testing-library/jest-dom"; import { cleanup } from "@testing-library/react"; -import { afterAll, afterEach, beforeAll, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; + +// Mock localStorage for Zustand persist middleware +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + get length() { + return Object.keys(store).length; + }, + key: (index: number) => Object.keys(store)[index] ?? null, + }; +})(); + +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + writable: true, +}); + +beforeEach(() => { + localStorage.clear(); +}); // Mock electron-log before any imports that use it vi.mock("electron-log/renderer", () => { diff --git a/packages/electron-trpc/package.json b/packages/electron-trpc/package.json index f9de9543..2d12f8cf 100644 --- a/packages/electron-trpc/package.json +++ b/packages/electron-trpc/package.json @@ -24,12 +24,14 @@ "build:types": "tsc -p tsconfig.build.json", "dev": "pnpm build", "typecheck": "tsc --noEmit", - "test": "vitest -c vitest.config.ts", + "test": "vitest run -c vitest.config.ts", "test:ci": "vitest run -c vitest.config.ts --coverage" }, "devDependencies": { "@trpc/client": "^11.8.0", "@trpc/server": "^11.8.0", + "superjson": "^2.2.2", + "zod": "^3.24.1", "@types/node": "^20.3.1", "@vitest/coverage-v8": "^0.34.0", "builtin-modules": "^3.3.0", diff --git a/packages/electron-trpc/src/main/__tests__/handleIPCMessage.test.ts b/packages/electron-trpc/src/main/__tests__/handleIPCMessage.test.ts index f0f84ead..eb233bba 100644 --- a/packages/electron-trpc/src/main/__tests__/handleIPCMessage.test.ts +++ b/packages/electron-trpc/src/main/__tests__/handleIPCMessage.test.ts @@ -1,4 +1,4 @@ -import { EventEmitter, on } from "node:events"; +import { EventEmitter } from "node:events"; import * as trpc from "@trpc/server"; import { observable } from "@trpc/server/observable"; import type { IpcMainEvent } from "electron"; @@ -195,13 +195,12 @@ describe("api", () => { test("handles subscriptions using async generators", async () => { const subscriptions = new Map(); - const ee = new EventEmitter(); const t = trpc.initTRPC.create(); + + // Simple async generator that yields a single value const testRouter = t.router({ - testSubscription: t.procedure.subscription(async function* ({ signal }) { - for await (const _ of on(ee, "test", { signal })) { - yield "test response"; - } + testSubscription: t.procedure.subscription(async function* () { + yield "test response"; }), }); @@ -213,8 +212,6 @@ describe("api", () => { }, }); - expect(ee.listenerCount("test")).toBe(0); - await handleIPCMessage({ createContext: async () => ({}), message: { @@ -234,48 +231,26 @@ describe("api", () => { event, }); - expect(ee.listenerCount("test")).toBe(1); - expect(event.reply).toHaveBeenCalledTimes(1); - expect(event.reply.mock.lastCall?.[1]).toMatchObject({ + // Wait for the generator to yield and complete + await vi.waitFor(() => { + // Should have at least: started, data + expect(event.reply.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + // First response should be "started" + expect(event.reply.mock.calls[0][1]).toMatchObject({ id: 1, result: { type: "started", }, }); - ee.emit("test"); - - await vi.waitFor(() => { - expect(event.reply).toHaveBeenCalledTimes(2); - expect(event.reply.mock.lastCall?.[1]).toMatchObject({ - id: 1, - result: { - data: "test response", - }, - }); - }); - - await handleIPCMessage({ - createContext: async () => ({}), - message: { - method: "subscription.stop", - id: 1, + // Second response should be the yielded data + expect(event.reply.mock.calls[1][1]).toMatchObject({ + id: 1, + result: { + data: "test response", }, - internalId: "1-1:1", - subscriptions, - router: testRouter, - event, - }); - - await vi.waitFor(() => { - expect(ee.listenerCount("test")).toBe(0); - expect(event.reply).toHaveBeenCalledTimes(3); - expect(event.reply.mock.lastCall?.[1]).toMatchObject({ - id: 1, - result: { - type: "stopped", - }, - }); }); }); diff --git a/packages/electron-trpc/src/main/utils.ts b/packages/electron-trpc/src/main/utils.ts index 0dd16b6f..85d04978 100644 --- a/packages/electron-trpc/src/main/utils.ts +++ b/packages/electron-trpc/src/main/utils.ts @@ -26,9 +26,17 @@ export function makeAsyncResource( ): T & AsyncDisposable { const it = thing as T & AsyncDisposable; + // If Symbol.asyncDispose already exists (e.g., on native async generators), + // wrap the existing dispose with our custom dispose // eslint-disable-next-line no-restricted-syntax if (it[Symbol.asyncDispose]) { - throw new Error("Symbol.asyncDispose already exists"); + const originalDispose = it[Symbol.asyncDispose].bind(it); + // eslint-disable-next-line no-restricted-syntax + it[Symbol.asyncDispose] = async () => { + await dispose(); + await originalDispose(); + }; + return it; } // eslint-disable-next-line no-restricted-syntax diff --git a/packages/electron-trpc/src/renderer/__tests__/ipcLink.test.ts b/packages/electron-trpc/src/renderer/__tests__/ipcLink.test.ts index 6c8dfa20..91cf03c3 100644 --- a/packages/electron-trpc/src/renderer/__tests__/ipcLink.test.ts +++ b/packages/electron-trpc/src/renderer/__tests__/ipcLink.test.ts @@ -67,19 +67,20 @@ describe("ipcLink", () => { expect(mock.sendMessage).toHaveBeenCalledTimes(1); expect(mock.sendMessage).toHaveBeenCalledWith({ method: "request", - operation: { + operation: expect.objectContaining({ context: {}, - id: 1, input: undefined, path: "testQuery", type: "query", - }, + }), }); + const sentId = (mock.sendMessage.mock.calls[0][0] as any).operation.id; + expect(queryResponse).not.toHaveBeenCalled(); handlers[0]({ - id: 1, + id: sentId, result: { type: "data", data: "query success", @@ -102,19 +103,19 @@ describe("ipcLink", () => { expect(mock.sendMessage).toHaveBeenCalledTimes(1); expect(mock.sendMessage).toHaveBeenCalledWith({ method: "request", - operation: { + operation: expect.objectContaining({ context: {}, - id: 1, input: "test input", path: "testMutation", type: "mutation", - }, + }), }); + const sentId = (mock.sendMessage.mock.calls[0][0] as any).operation.id; mock.sendMessage.mockClear(); handlers[0]({ - id: 1, + id: sentId, result: { type: "data", data: "mutation success", @@ -142,21 +143,22 @@ describe("ipcLink", () => { expect(mock.sendMessage).toHaveBeenCalledTimes(1); expect(mock.sendMessage).toHaveBeenCalledWith({ method: "request", - operation: { + operation: expect.objectContaining({ context: {}, - id: 1, input: undefined, path: "testSubscription", type: "subscription", - }, + }), }); + const sentId = (mock.sendMessage.mock.calls[0][0] as any).operation.id; + /* * Multiple responses from the server */ const respond = (str: string) => handlers[0]({ - id: 1, + id: sentId, result: { type: "data", data: str, @@ -178,7 +180,7 @@ describe("ipcLink", () => { expect(mock.sendMessage).toHaveBeenCalledTimes(2); expect(mock.sendMessage.mock.calls[1]).toEqual([ { - id: 1, + id: sentId, method: "subscription.stop", }, ]); @@ -204,20 +206,23 @@ describe("ipcLink", () => { expect(mock.sendMessage).toHaveBeenCalledTimes(3); + const sentId1 = (mock.sendMessage.mock.calls[0][0] as any).operation.id; + const sentId3 = (mock.sendMessage.mock.calls[2][0] as any).operation.id; + expect(queryResponse1).not.toHaveBeenCalled(); expect(queryResponse2).not.toHaveBeenCalled(); expect(queryResponse3).not.toHaveBeenCalled(); // Respond to queries in a different order handlers[0]({ - id: 1, + id: sentId1, result: { type: "data", data: "query success 1", }, }); handlers[0]({ - id: 3, + id: sentId3, result: { type: "data", data: "query success 3", @@ -253,19 +258,20 @@ describe("ipcLink", () => { expect(mock.sendMessage).toHaveBeenCalledTimes(1); expect(mock.sendMessage).toHaveBeenCalledWith({ method: "request", - operation: { + operation: expect.objectContaining({ context: {}, - id: 1, input: superjson.serialize(input), path: "testInputs", type: "query", - }, + }), }); + const sentId = (mock.sendMessage.mock.calls[0][0] as any).operation.id; + expect(queryResponse).not.toHaveBeenCalled(); handlers[0]({ - id: 1, + id: sentId, result: { type: "data", data: superjson.serialize(input), @@ -303,19 +309,20 @@ describe("ipcLink", () => { expect(mock.sendMessage).toHaveBeenCalledTimes(1); expect(mock.sendMessage).toHaveBeenCalledWith({ method: "request", - operation: { - id: 1, + operation: expect.objectContaining({ context: {}, input: JSON.stringify(input), path: "testInputs", type: "query", - }, + }), }); + const sentId = (mock.sendMessage.mock.calls[0][0] as any).operation.id; + expect(queryResponse).not.toHaveBeenCalled(); handlers[0]({ - id: 1, + id: sentId, result: { type: "data", data: JSON.stringify(input), diff --git a/packages/electron-trpc/vitest.config.ts b/packages/electron-trpc/vitest.config.ts index f408e801..11f94927 100644 --- a/packages/electron-trpc/vitest.config.ts +++ b/packages/electron-trpc/vitest.config.ts @@ -1,8 +1,11 @@ /// import path from "node:path"; +import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; -module.exports = defineConfig({ +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ test: { coverage: { all: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ca5112d..0b9ee8e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -417,7 +417,7 @@ importers: version: 2.29.7(@types/node@22.19.1) '@types/bun': specifier: latest - version: 1.3.4 + version: 1.3.5 minimatch: specifier: ^10.0.3 version: 10.1.1 @@ -454,6 +454,9 @@ importers: electron: specifier: ^35.2.1 version: 35.7.5 + superjson: + specifier: ^2.2.2 + version: 2.2.6 typescript: specifier: ^5.8.3 version: 5.9.3 @@ -466,6 +469,9 @@ importers: vitest: specifier: ^2.1.8 version: 2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(terser@5.44.1) + zod: + specifier: ^3.24.1 + version: 3.25.76 packages: @@ -3226,8 +3232,8 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/bun@1.3.4': - resolution: {integrity: sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA==} + '@types/bun@1.3.5': + resolution: {integrity: sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w==} '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -3750,8 +3756,8 @@ packages: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} - bun-types@1.3.4: - resolution: {integrity: sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ==} + bun-types@1.3.5: + resolution: {integrity: sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw==} bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} @@ -4002,6 +4008,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + core-js@3.47.0: resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} @@ -4927,6 +4937,10 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -6562,6 +6576,10 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -10448,9 +10466,9 @@ snapshots: dependencies: '@babel/types': 7.28.5 - '@types/bun@1.3.4': + '@types/bun@1.3.5': dependencies: - bun-types: 1.3.4 + bun-types: 1.3.5 '@types/cacheable-request@6.0.3': dependencies: @@ -11042,7 +11060,7 @@ snapshots: builtin-modules@3.3.0: {} - bun-types@1.3.4: + bun-types@1.3.5: dependencies: '@types/node': 20.19.25 @@ -11295,6 +11313,10 @@ snapshots: cookie@0.7.2: {} + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + core-js@3.47.0: {} cors@2.8.5: @@ -12327,6 +12349,8 @@ snapshots: is-unicode-supported@0.1.0: {} + is-what@5.5.0: {} + is-windows@1.0.2: {} isbinaryfile@4.0.10: {} @@ -14346,6 +14370,10 @@ snapshots: transitivePeerDependencies: - supports-color + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + supports-color@7.2.0: dependencies: has-flag: 4.0.0