diff --git a/src/__tests__/version-check.test.ts b/src/__tests__/version-check.test.ts new file mode 100644 index 0000000..d5d30c1 --- /dev/null +++ b/src/__tests__/version-check.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { compareSemver, getUpdateType, checkForUpdates } from '../utils/version-check'; +import { logger } from '../utils/logger'; + +vi.mock('../utils/logger', () => ({ + logger: { + error: vi.fn(), + }, +})); + +describe('version-check utilities', () => { + describe('compareSemver', () => { + it('should return "lt" if a < b', () => { + expect(compareSemver('1.0.0', '1.0.1')).toBe('lt'); + expect(compareSemver('1.0.0', '1.1.0')).toBe('lt'); + expect(compareSemver('1.0.0', '2.0.0')).toBe('lt'); + }); + + it('should return "gt" if a > b', () => { + expect(compareSemver('1.0.1', '1.0.0')).toBe('gt'); + expect(compareSemver('1.1.0', '1.0.0')).toBe('gt'); + expect(compareSemver('2.0.0', '1.0.0')).toBe('gt'); + }); + + it('should return "eq" if a === b', () => { + expect(compareSemver('1.0.0', '1.0.0')).toBe('eq'); + expect(compareSemver('v1.0.0', '1.0.0')).toBe('eq'); + }); + }); + + describe('getUpdateType', () => { + it('should return "major" for major updates', () => { + expect(getUpdateType('1.0.0', '2.0.0')).toBe('major'); + }); + + it('should return "minor" for minor updates', () => { + expect(getUpdateType('1.0.0', '1.1.0')).toBe('minor'); + }); + + it('should return "patch" for patch updates', () => { + expect(getUpdateType('1.0.0', '1.0.1')).toBe('patch'); + }); + + it('should return empty string if versions are equal', () => { + expect(getUpdateType('1.0.0', '1.0.0')).toBe(''); + }); + + it('should return empty string for downgrades', () => { + expect(getUpdateType('2.0.0', '1.0.0')).toBe(''); + expect(getUpdateType('1.1.0', '1.0.0')).toBe(''); + expect(getUpdateType('1.0.1', '1.0.0')).toBe(''); + }); + }); + + describe('checkForUpdates', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('should log an error if registry response is invalid', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ not_a_version: '1.2.3' }), + }); + + await checkForUpdates(); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Invalid registry response')); + }); + + it('should handle fetch timeout/error gracefully', async () => { + fetchMock.mockRejectedValue(new Error('Timeout')); + + await checkForUpdates(); + // Should not throw and should not log error (it fails silently in the catch block for network errors) + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should log update message if a newer version is available', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ version: '9.9.9' }), + }); + + const consoleSpy = vi.spyOn(console, 'log'); + await checkForUpdates(); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('update available!')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('v9.9.9')); + }); + }); +}); diff --git a/src/utils/version-check.ts b/src/utils/version-check.ts index 618ed32..528f1f1 100644 --- a/src/utils/version-check.ts +++ b/src/utils/version-check.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { logger } from './logger'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -18,7 +19,7 @@ function getInstalledVersion(): string { } // Compare two semver strings; returns 'lt' if a < b, 'gt' if a > b, 'eq' if equal -function compareSemver(a: string, b: string): 'lt' | 'gt' | 'eq' { +export function compareSemver(a: string, b: string): 'lt' | 'gt' | 'eq' { const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number); const [a1, a2, a3] = parse(a); const [b1, b2, b3] = parse(b); @@ -29,23 +30,15 @@ function compareSemver(a: string, b: string): 'lt' | 'gt' | 'eq' { } // Determine update type for messaging -function getUpdateType(installed: string, latest: string): string { +export function getUpdateType(installed: string, latest: string): string { const cmp = compareSemver(installed, latest); - if (cmp === 'eq') return ''; - const [i1] = installed.replace(/^v/, '').split('.').map(Number); - const [l1] = latest.replace(/^v/, '').split('.').map(Number); - if (l1 > i1) return 'major'; -function getUpdateType(installed: string, latest: string): string { - const cmp = compareSemver(installed, latest); - if (cmp === 'eq') return ''; + if (cmp === 'eq' || cmp === 'gt') return ''; const [i1 = 0, i2 = 0] = installed.replace(/^v/, '').split('.').map(Number); const [l1 = 0, l2 = 0] = latest.replace(/^v/, '').split('.').map(Number); if (l1 > i1) return 'major'; if (l2 > i2) return 'minor'; return 'patch'; } - return 'patch'; -} export async function checkForUpdates(): Promise { try { @@ -54,7 +47,13 @@ export async function checkForUpdates(): Promise { }); if (!response.ok) return; - const data = await response.json() as { version: string }; + const data = (await response.json()) as unknown; + + if (!data || typeof data !== 'object' || !('version' in data) || typeof data.version !== 'string') { + logger.error('Invalid registry response: version not found'); + return; + } + const latestVersion = data.version; const installedVersion = getInstalledVersion();