import { beforeEach, describe, expect, it, vi } from 'vitest'; import { semverLt, formatUpdateNotice } from '../src/runtime/update-checker.js'; import type { UpdateCheckResult } from '../src/runtime/update-checker.js'; const { execSyncMock, cacheFiles } = vi.hoisted(() => ({ execSyncMock: vi.fn(), cacheFiles: new Map(), })); vi.mock('node:child_process', () => ({ execSync: execSyncMock, })); vi.mock('node:fs', () => ({ existsSync: vi.fn((path: string) => cacheFiles.has(path)), mkdirSync: vi.fn(), readFileSync: vi.fn((path: string) => { const value = cacheFiles.get(path); if (value === undefined) { throw new Error(`ENOENT: ${path}`); } return value; }), writeFileSync: vi.fn((path: string, content: string) => { cacheFiles.set(path, content); }), })); vi.mock('node:os', () => ({ homedir: vi.fn(() => '/mock-home'), })); async function importUpdateChecker() { vi.resetModules(); return import('../src/runtime/update-checker.js'); } describe('semverLt', () => { it('returns true when a < b', () => { expect(semverLt('0.0.1', '0.0.2')).toBe(true); expect(semverLt('0.1.0', '0.2.0')).toBe(true); expect(semverLt('1.0.0', '2.0.0')).toBe(true); expect(semverLt('0.0.1-alpha.1', '0.0.1-alpha.2')).toBe(true); expect(semverLt('0.0.1-alpha.1', '0.0.1')).toBe(true); }); it('returns false when a >= b', () => { expect(semverLt('0.0.2', '0.0.1')).toBe(false); expect(semverLt('1.0.0', '1.0.0')).toBe(false); expect(semverLt('2.0.0', '1.0.0')).toBe(false); }); it('returns false for empty strings', () => { expect(semverLt('', '1.0.0')).toBe(false); expect(semverLt('1.0.0', '')).toBe(false); expect(semverLt('', '')).toBe(false); }); }); describe('formatUpdateNotice', () => { beforeEach(() => { execSyncMock.mockReset(); cacheFiles.clear(); }); it('returns empty string when up to date', () => { const result: UpdateCheckResult = { current: '1.0.0', latest: '1.0.0', updateAvailable: false, checkedAt: new Date().toISOString(), registry: 'https://example.com', }; expect(formatUpdateNotice(result)).toBe(''); }); it('returns a notice when update is available', () => { const result: UpdateCheckResult = { current: '0.0.1', latest: '0.1.0', updateAvailable: true, checkedAt: new Date().toISOString(), registry: 'https://example.com', }; const notice = formatUpdateNotice(result); expect(notice).toContain('0.0.1'); expect(notice).toContain('0.1.0'); expect(notice).toContain('Update available'); }); it('uses @mosaic/mosaic hints for modern installs', async () => { execSyncMock.mockImplementation((command: string) => { if (command.includes('ls -g --depth=0 --json')) { return JSON.stringify({ dependencies: { '@mosaic/mosaic': { version: '0.0.17' }, '@mosaic/cli': { version: '0.0.16' }, }, }); } if (command.includes('view @mosaic/mosaic version')) { return '0.0.18'; } throw new Error(`Unexpected command: ${command}`); }); const { checkForUpdate } = await importUpdateChecker(); const result = checkForUpdate({ skipCache: true }); const notice = formatUpdateNotice(result); expect(result.current).toBe('0.0.17'); expect(result.latest).toBe('0.0.18'); expect(notice).toContain('@mosaic/mosaic@latest'); expect(notice).not.toContain('@mosaic/cli@latest'); }); it('falls back to @mosaic/mosaic for legacy @mosaic/cli installs when cli is unavailable', async () => { execSyncMock.mockImplementation((command: string) => { if (command.includes('ls -g --depth=0 --json')) { return JSON.stringify({ dependencies: { '@mosaic/cli': { version: '0.0.16' }, }, }); } if (command.includes('view @mosaic/cli version')) { throw new Error('not found'); } if (command.includes('view @mosaic/mosaic version')) { return '0.0.17'; } throw new Error(`Unexpected command: ${command}`); }); const { checkForUpdate } = await importUpdateChecker(); const result = checkForUpdate({ skipCache: true }); const notice = formatUpdateNotice(result); expect(result.current).toBe('0.0.16'); expect(result.latest).toBe('0.0.17'); expect(notice).toContain('@mosaic/mosaic@latest'); }); it('does not reuse a cached modern-package result for a legacy install', async () => { let installedPackage = '@mosaic/mosaic'; execSyncMock.mockImplementation((command: string) => { if (command.includes('ls -g --depth=0 --json')) { return JSON.stringify({ dependencies: installedPackage === '@mosaic/mosaic' ? { '@mosaic/mosaic': { version: '0.0.17' } } : { '@mosaic/cli': { version: '0.0.16' } }, }); } if (command.includes('view @mosaic/mosaic version')) { return installedPackage === '@mosaic/mosaic' ? '0.0.18' : '0.0.17'; } if (command.includes('view @mosaic/cli version')) { throw new Error('not found'); } throw new Error(`Unexpected command: ${command}`); }); const { checkForUpdate } = await importUpdateChecker(); const modernResult = checkForUpdate(); installedPackage = '@mosaic/cli'; const legacyResult = checkForUpdate(); expect(modernResult.currentPackage).toBe('@mosaic/mosaic'); expect(modernResult.targetPackage).toBe('@mosaic/mosaic'); expect(modernResult.latest).toBe('0.0.18'); expect(legacyResult.currentPackage).toBe('@mosaic/cli'); expect(legacyResult.targetPackage).toBe('@mosaic/mosaic'); expect(legacyResult.latest).toBe('0.0.17'); expect(execSyncMock).toHaveBeenCalledWith( expect.stringContaining('view @mosaic/cli version'), expect.any(Object), ); expect(execSyncMock).toHaveBeenCalledWith( expect.stringContaining('view @mosaic/mosaic version'), expect.any(Object), ); }); });