From 9a3b48c25650b33cab468346fd2e6daa54db6285 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:50:50 +0100 Subject: [PATCH] auto-claude: 156-fix-electron-app-version-detection-bug (#1459) * auto-claude: subtask-1-1 - Add cache invalidation when installed > cached latest Modified fetchLatestVersion() to accept optional currentInstalled parameter. When the installed CLI version is newer than the cached npm latest version, the cache is invalidated and fresh data is fetched from the npm registry. This fixes the "future version" bug where CLI updates while the app is running would display inverted version information (installed > latest). Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-2-1 - Create unit tests for cache invalidation when inst Add comprehensive unit tests for the cache invalidation logic in claude-code-handlers.ts. Tests verify: - Cache invalidation when installed > cached latest (triggers refetch) - Cache preserved when installed <= cached latest - Cache preserved when installed equals cached - Handling of versions with v prefix - Graceful fallback for invalid semver strings - Null installed version handling (CLI not found) - Network error handling (returns unknown when cache cleared + fetch fails) - Pre-release version handling (beta > stable triggers invalidation) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .../__tests__/claude-code-handlers.test.ts | 462 ++++++++++++++++++ .../main/ipc-handlers/claude-code-handlers.ts | 32 +- 2 files changed, 491 insertions(+), 3 deletions(-) create mode 100644 apps/frontend/src/main/__tests__/claude-code-handlers.test.ts diff --git a/apps/frontend/src/main/__tests__/claude-code-handlers.test.ts b/apps/frontend/src/main/__tests__/claude-code-handlers.test.ts new file mode 100644 index 00000000..fc6a94d5 --- /dev/null +++ b/apps/frontend/src/main/__tests__/claude-code-handlers.test.ts @@ -0,0 +1,462 @@ +/** + * Tests for claude-code-handlers.ts + * + * Tests the cache invalidation logic when the installed CLI version + * is newer than the cached latest version from npm registry. + */ + +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Store registered IPC handlers so we can call them directly +type IpcHandler = (event: unknown, ...args: unknown[]) => Promise; +const registeredHandlers: Map = new Map(); + +// Mock ipcMain to capture registered handlers +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: IpcHandler) => { + registeredHandlers.set(channel, handler); + }), + }, +})); + +// Mock cli-tool-manager +const mockGetToolInfo = vi.fn(); +vi.mock('../cli-tool-manager', () => ({ + getToolInfo: mockGetToolInfo, + configureTools: vi.fn(), + getClaudeDetectionPaths: vi.fn(() => ({ + homebrewPaths: [], + platformPaths: [], + nvmVersionsDir: '', + })), + sortNvmVersionDirs: vi.fn(() => []), +})); + +// Mock settings-utils +vi.mock('../settings-utils', () => ({ + readSettingsFile: vi.fn(() => ({})), + writeSettingsFile: vi.fn(), +})); + +// Mock utils/windows-paths +vi.mock('../utils/windows-paths', () => ({ + isSecurePath: vi.fn(() => true), +})); + +// Mock utils/config-path-validator +vi.mock('../utils/config-path-validator', () => ({ + isValidConfigDir: vi.fn(() => true), +})); + +// Mock claude-profile-manager +vi.mock('../claude-profile-manager', () => ({ + getClaudeProfileManager: vi.fn(() => ({ + getProfile: vi.fn(), + saveProfile: vi.fn(), + setProfileToken: vi.fn(), + })), +})); + +// Mock fs and child_process +vi.mock('fs', () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(), + promises: { + readdir: vi.fn(() => Promise.resolve([])), + mkdir: vi.fn(() => Promise.resolve()), + rename: vi.fn(() => Promise.resolve()), + unlink: vi.fn(() => Promise.resolve()), + }, +})); + +vi.mock('child_process', () => ({ + exec: vi.fn(), + execFile: vi.fn(), + execFileSync: vi.fn(), + spawn: vi.fn(() => ({ unref: vi.fn() })), +})); + +// Mock global fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +// Import after mocks are set up +import { IPC_CHANNELS } from '../../shared/constants'; + +describe('claude-code-handlers - Cache Invalidation', () => { + let checkVersionHandler: IpcHandler; + + beforeEach(async () => { + vi.clearAllMocks(); + registeredHandlers.clear(); + + // Reset module cache to get fresh state + vi.resetModules(); + + // Re-import to re-register handlers with fresh cache state + const { registerClaudeCodeHandlers } = await import('../ipc-handlers/claude-code-handlers'); + registerClaudeCodeHandlers(); + + // Get the check version handler + const handler = registeredHandlers.get(IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION); + if (!handler) { + throw new Error('CLAUDE_CODE_CHECK_VERSION handler not registered'); + } + checkVersionHandler = handler; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('when installed version is newer than cached latest', () => { + test('should invalidate cache and refetch from npm', async () => { + // Setup: CLI returns installed version 2.1.16 + mockGetToolInfo.mockReturnValue({ + found: true, + version: '2.1.16', + path: '/usr/local/bin/claude', + source: 'system-path', + message: 'Found', + }); + + // First call: npm returns 2.1.15, gets cached + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '2.1.15' }), + }); + + // Call to populate cache with 2.1.15 + const firstResult = await checkVersionHandler({}, null) as { + success: boolean; + data?: { installed: string | null; latest: string }; + }; + expect(firstResult.success).toBe(true); + expect(firstResult.data?.latest).toBe('2.1.15'); + + // Now npm has 2.1.16 (matching installed) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '2.1.16' }), + }); + + // Second call: installed (2.1.16) > cached (2.1.15), should refetch + const secondResult = await checkVersionHandler({}, null) as { + success: boolean; + data?: { installed: string | null; latest: string }; + }; + expect(secondResult.success).toBe(true); + expect(secondResult.data?.installed).toBe('2.1.16'); + expect(secondResult.data?.latest).toBe('2.1.16'); + + // Verify fetch was called twice (once for initial, once after invalidation) + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('when installed version equals cached latest', () => { + test('should use cached value without refetching', async () => { + // Setup: CLI returns installed version 2.1.15 + mockGetToolInfo.mockReturnValue({ + found: true, + version: '2.1.15', + path: '/usr/local/bin/claude', + source: 'system-path', + message: 'Found', + }); + + // npm returns 2.1.15 + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '2.1.15' }), + }); + + // First call to populate cache + const firstResult = await checkVersionHandler({}, null) as { + success: boolean; + data?: { latest: string }; + }; + expect(firstResult.success).toBe(true); + expect(firstResult.data?.latest).toBe('2.1.15'); + + // Second call: installed (2.1.15) = cached (2.1.15), should use cache + const secondResult = await checkVersionHandler({}, null) as { + success: boolean; + data?: { latest: string }; + }; + expect(secondResult.success).toBe(true); + expect(secondResult.data?.latest).toBe('2.1.15'); + + // Verify fetch was called only once (cache used for second call) + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('when installed version is older than cached latest', () => { + test('should use cached value without refetching', async () => { + // Setup: CLI returns installed version 2.1.14 (older) + mockGetToolInfo.mockReturnValue({ + found: true, + version: '2.1.14', + path: '/usr/local/bin/claude', + source: 'system-path', + message: 'Found', + }); + + // npm returns 2.1.16 (newer) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '2.1.16' }), + }); + + // First call to populate cache + const firstResult = await checkVersionHandler({}, null) as { + success: boolean; + data?: { latest: string; isOutdated: boolean }; + }; + expect(firstResult.success).toBe(true); + expect(firstResult.data?.latest).toBe('2.1.16'); + expect(firstResult.data?.isOutdated).toBe(true); + + // Second call: installed (2.1.14) < cached (2.1.16), should use cache + const secondResult = await checkVersionHandler({}, null) as { + success: boolean; + data?: { latest: string; isOutdated: boolean }; + }; + expect(secondResult.success).toBe(true); + expect(secondResult.data?.latest).toBe('2.1.16'); + expect(secondResult.data?.isOutdated).toBe(true); + + // Verify fetch was called only once + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('version handling edge cases', () => { + test('should handle versions with v prefix', async () => { + // Setup: CLI returns version with 'v' prefix + mockGetToolInfo.mockReturnValue({ + found: true, + version: 'v2.1.16', + path: '/usr/local/bin/claude', + source: 'system-path', + message: 'Found', + }); + + // First call: npm returns v2.1.15 + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: 'v2.1.15' }), + }); + + // Populate cache with v2.1.15 + await checkVersionHandler({}, null); + + // Now npm has v2.1.16 + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: 'v2.1.16' }), + }); + + // Second call should invalidate and refetch + const result = await checkVersionHandler({}, null) as { + success: boolean; + data?: { latest: string }; + }; + expect(result.success).toBe(true); + expect(result.data?.latest).toBe('v2.1.16'); + + // Cache should have been invalidated + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + test('should handle invalid semver gracefully (falls back to cached)', async () => { + // Setup: CLI returns invalid version string + mockGetToolInfo.mockReturnValue({ + found: true, + version: 'not-a-valid-version', + path: '/usr/local/bin/claude', + source: 'system-path', + message: 'Found', + }); + + // npm returns valid version + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '2.1.15' }), + }); + + // First call to populate cache + await checkVersionHandler({}, null); + + // Second call: invalid installed version should fall back to cached + const result = await checkVersionHandler({}, null) as { + success: boolean; + data?: { latest: string }; + }; + expect(result.success).toBe(true); + expect(result.data?.latest).toBe('2.1.15'); + + // Should only fetch once (cached value used) + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + test('should handle null installed version (CLI not found)', async () => { + // Setup: CLI not found + mockGetToolInfo.mockReturnValue({ + found: false, + version: null, + path: null, + source: 'fallback', + message: 'Not found', + }); + + // npm returns version + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '2.1.16' }), + }); + + // First call to populate cache + await checkVersionHandler({}, null); + + // Second call: null installed should use cache + const result = await checkVersionHandler({}, null) as { + success: boolean; + data?: { installed: string | null; latest: string }; + }; + expect(result.success).toBe(true); + expect(result.data?.installed).toBeNull(); + expect(result.data?.latest).toBe('2.1.16'); + + // Should only fetch once + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('network error handling', () => { + test('should return unknown when cache invalidation triggers refetch that fails', async () => { + // Setup: CLI returns installed version 2.1.16 + mockGetToolInfo.mockReturnValue({ + found: true, + version: '2.1.16', + path: '/usr/local/bin/claude', + source: 'system-path', + message: 'Found', + }); + + // First call: npm returns 2.1.15, gets cached + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '2.1.15' }), + }); + + await checkVersionHandler({}, null); + + // Network error on second call + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + // Second call: installed > cached triggers cache invalidation and refetch + // When refetch fails after cache invalidation, the stale cache is already cleared + // so we get 'unknown' as the fallback + const result = await checkVersionHandler({}, null) as { + success: boolean; + data?: { latest: string }; + }; + expect(result.success).toBe(true); + // After cache invalidation + network failure, returns unknown + expect(result.data?.latest).toBe('unknown'); + }); + + test('should return cached value on network error when cache is still valid', async () => { + // Setup: CLI returns installed version 2.1.14 (older than cached) + mockGetToolInfo.mockReturnValue({ + found: true, + version: '2.1.14', + path: '/usr/local/bin/claude', + source: 'system-path', + message: 'Found', + }); + + // First call: npm returns 2.1.15, gets cached + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '2.1.15' }), + }); + + await checkVersionHandler({}, null); + + // Since installed (2.1.14) < cached (2.1.15), cache won't be invalidated + // The cached value will be returned without making another fetch call + const result = await checkVersionHandler({}, null) as { + success: boolean; + data?: { latest: string }; + }; + expect(result.success).toBe(true); + expect(result.data?.latest).toBe('2.1.15'); + + // Only one fetch call should have been made + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + test('should return unknown when fetch fails and no cache exists', async () => { + // Setup: CLI found + mockGetToolInfo.mockReturnValue({ + found: true, + version: '2.1.16', + path: '/usr/local/bin/claude', + source: 'system-path', + message: 'Found', + }); + + // Network error on first call (no cache) + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await checkVersionHandler({}, null) as { + success: boolean; + data?: { latest: string }; + }; + expect(result.success).toBe(true); + expect(result.data?.latest).toBe('unknown'); + }); + }); + + describe('pre-release version handling', () => { + test('should invalidate cache when beta installed is newer than cached stable', async () => { + // Setup: CLI returns installed beta version 2.1.16-beta.1 + mockGetToolInfo.mockReturnValue({ + found: true, + version: '2.1.16-beta.1', + path: '/usr/local/bin/claude', + source: 'system-path', + message: 'Found', + }); + + // First call: npm returns stable 2.1.15 + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '2.1.15' }), + }); + + await checkVersionHandler({}, null); + + // npm now has 2.1.16 + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ version: '2.1.16' }), + }); + + // Beta 2.1.16-beta.1 > stable 2.1.15, should invalidate + const result = await checkVersionHandler({}, null) as { + success: boolean; + data?: { latest: string }; + }; + expect(result.success).toBe(true); + expect(result.data?.latest).toBe('2.1.16'); + + // Cache should have been invalidated + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts index 7b3bee78..d9636543 100644 --- a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts @@ -215,11 +215,36 @@ async function scanClaudeInstallations(activePath: string | null): Promise { +async function fetchLatestVersion(currentInstalled?: string | null): Promise { // Check cache first if (cachedLatestVersion && Date.now() - cachedLatestVersion.timestamp < CACHE_DURATION_MS) { - return cachedLatestVersion.version; + const cachedVersion = cachedLatestVersion.version; + + // Invalidate cache if installed version is newer than cached latest + // This handles the case where CLI was updated while app was running + if (currentInstalled && cachedVersion) { + try { + const cleanInstalled = currentInstalled.replace(/^v/, ''); + const cleanCached = cachedVersion.replace(/^v/, ''); + if (semver.valid(cleanInstalled) && semver.valid(cleanCached) && + semver.gt(cleanInstalled, cleanCached)) { + console.warn('[Claude Code] Installed version newer than cached latest, invalidating cache'); + cachedLatestVersion = null; + // Fall through to fetch fresh from npm + } else { + return cachedVersion; + } + } catch { + // If semver comparison fails, return cached version + return cachedVersion; + } + } else { + return cachedVersion; + } } try { @@ -919,10 +944,11 @@ export function registerClaudeCodeHandlers(): void { console.warn('[Claude Code] Installed version:', installed); // Fetch latest version from npm + // Pass installed version to invalidate cache if installed > cached (handles CLI update while app running) let latest: string; try { console.warn('[Claude Code] Fetching latest version from npm...'); - latest = await fetchLatestVersion(); + latest = await fetchLatestVersion(installed); console.warn('[Claude Code] Latest version:', latest); } catch (error) { console.warn('[Claude Code] Failed to fetch latest version, continuing with unknown:', error);