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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-24 14:50:50 +01:00
committed by GitHub
parent 0c29908158
commit 9a3b48c256
2 changed files with 491 additions and 3 deletions
@@ -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<unknown>;
const registeredHandlers: Map<string, IpcHandler> = 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);
});
});
});
@@ -215,11 +215,36 @@ async function scanClaudeInstallations(activePath: string | null): Promise<Claud
/**
* Fetch the latest version of Claude Code from npm registry
* @param currentInstalled - Optional currently installed version. If provided and newer than
* cached latest, cache will be invalidated and fresh data fetched.
* This handles the case where CLI was updated while app was running.
*/
async function fetchLatestVersion(): Promise<string> {
async function fetchLatestVersion(currentInstalled?: string | null): Promise<string> {
// 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);