diff --git a/apps/frontend/src/main/__tests__/version-manager.test.ts b/apps/frontend/src/main/__tests__/version-manager.test.ts new file mode 100644 index 00000000..c187f811 --- /dev/null +++ b/apps/frontend/src/main/__tests__/version-manager.test.ts @@ -0,0 +1,101 @@ +/** + * Tests for version-manager.ts + * + * Tests the compareVersions function with various version formats + * including pre-release versions (alpha, beta, rc). + */ + +import { describe, test, expect } from 'vitest'; +import { compareVersions } from '../updater/version-manager'; + +describe('compareVersions', () => { + describe('basic version comparison', () => { + test('equal versions return 0', () => { + expect(compareVersions('1.0.0', '1.0.0')).toBe(0); + expect(compareVersions('2.7.2', '2.7.2')).toBe(0); + }); + + test('newer major version returns 1', () => { + expect(compareVersions('2.0.0', '1.0.0')).toBe(1); + expect(compareVersions('3.0.0', '2.7.2')).toBe(1); + }); + + test('older major version returns -1', () => { + expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); + }); + + test('newer minor version returns 1', () => { + expect(compareVersions('2.8.0', '2.7.2')).toBe(1); + }); + + test('older minor version returns -1', () => { + expect(compareVersions('2.6.0', '2.7.2')).toBe(-1); + }); + + test('newer patch version returns 1', () => { + expect(compareVersions('2.7.3', '2.7.2')).toBe(1); + }); + + test('older patch version returns -1', () => { + expect(compareVersions('2.7.1', '2.7.2')).toBe(-1); + }); + }); + + describe('pre-release version comparison', () => { + test('stable is newer than same-version beta', () => { + expect(compareVersions('2.7.2', '2.7.2-beta.6')).toBe(1); + expect(compareVersions('2.7.2-beta.6', '2.7.2')).toBe(-1); + }); + + test('stable is newer than same-version alpha', () => { + expect(compareVersions('2.7.2', '2.7.2-alpha.1')).toBe(1); + }); + + test('beta is newer than alpha of same version', () => { + expect(compareVersions('2.7.2-beta.1', '2.7.2-alpha.1')).toBe(1); + }); + + test('rc is newer than beta of same version', () => { + expect(compareVersions('2.7.2-rc.1', '2.7.2-beta.6')).toBe(1); + }); + + test('higher beta number is newer', () => { + expect(compareVersions('2.7.2-beta.7', '2.7.2-beta.6')).toBe(1); + expect(compareVersions('2.7.2-beta.6', '2.7.2-beta.7')).toBe(-1); + }); + + test('equal pre-release versions return 0', () => { + expect(compareVersions('2.7.2-beta.6', '2.7.2-beta.6')).toBe(0); + }); + }); + + describe('cross-version pre-release comparison', () => { + test('beta of newer version is newer than stable of older version', () => { + // 2.7.2-beta.1 > 2.7.1 (stable) + expect(compareVersions('2.7.2-beta.1', '2.7.1')).toBe(1); + }); + + test('stable of older version is older than beta of newer version', () => { + // 2.7.1 (stable) < 2.7.2-beta.6 + expect(compareVersions('2.7.1', '2.7.2-beta.6')).toBe(-1); + }); + + // THIS IS THE BUG WE'RE FIXING: + // When on 2.7.2-beta.6, the updater was offering 2.7.1 as an "update" + test('stable 2.7.1 is NOT newer than beta 2.7.2-beta.6', () => { + expect(compareVersions('2.7.1', '2.7.2-beta.6')).toBe(-1); + }); + }); + + describe('edge cases', () => { + test('handles versions with missing parts', () => { + expect(compareVersions('2.7', '2.7.0')).toBe(0); + expect(compareVersions('2', '2.0.0')).toBe(0); + }); + + test('handles pre-release without number', () => { + // "beta" without a number should be treated as beta.0 + expect(compareVersions('2.7.2-beta', '2.7.2-beta.1')).toBe(-1); + }); + }); +}); diff --git a/apps/frontend/src/main/app-updater.ts b/apps/frontend/src/main/app-updater.ts index cc35270b..3a6ee980 100644 --- a/apps/frontend/src/main/app-updater.ts +++ b/apps/frontend/src/main/app-updater.ts @@ -22,6 +22,7 @@ import { app } from 'electron'; import type { BrowserWindow } from 'electron'; import { IPC_CHANNELS } from '../shared/constants'; import type { AppUpdateInfo } from '../shared/types'; +import { compareVersions } from './updater/version-manager'; // Debug mode - DEBUG_UPDATER=true or development mode const DEBUG_UPDATER = process.env.DEBUG_UPDATER === 'true' || process.env.NODE_ENV === 'development'; @@ -197,9 +198,16 @@ export async function checkForUpdates(): Promise { return null; } - const updateAvailable = result.updateInfo.version !== autoUpdater.currentVersion.version; + const currentVersion = autoUpdater.currentVersion.version; + const latestVersion = result.updateInfo.version; + + // Use proper semver comparison to detect if update is actually newer + // This prevents offering downgrades (e.g., v2.7.1 when on v2.7.2-beta.6) + const isNewer = compareVersions(latestVersion, currentVersion) > 0; + + console.warn(`[app-updater] Version comparison: ${latestVersion} vs ${currentVersion} -> ${isNewer ? 'UPDATE' : 'NO UPDATE'}`); - if (!updateAvailable) { + if (!isNewer) { return null; } diff --git a/apps/frontend/src/main/updater/version-manager.ts b/apps/frontend/src/main/updater/version-manager.ts index 80401872..bc850e22 100644 --- a/apps/frontend/src/main/updater/version-manager.ts +++ b/apps/frontend/src/main/updater/version-manager.ts @@ -92,20 +92,78 @@ export function parseVersionFromTag(tag: string): string { } /** - * Compare semantic versions + * Parse a version string into its components + * Handles versions like "2.7.2", "2.7.2-beta.6", "2.7.2-alpha.1" + * + * @returns { base: number[], prerelease: { type: string, num: number } | null } + */ +function parseVersion(version: string): { + base: number[]; + prerelease: { type: string; num: number } | null +} { + // Split into base version and prerelease suffix + // e.g., "2.7.2-beta.6" -> ["2.7.2", "beta.6"] + const [baseStr, prereleaseStr] = version.split('-'); + + // Parse base version numbers + const base = baseStr.split('.').map(n => parseInt(n, 10) || 0); + + // Parse prerelease if present + let prerelease: { type: string; num: number } | null = null; + if (prereleaseStr) { + // Handle formats like "beta.6", "alpha.1", "rc.2" + const match = prereleaseStr.match(/^([a-zA-Z]+)\.?(\d*)$/); + if (match) { + prerelease = { + type: match[1].toLowerCase(), + num: parseInt(match[2], 10) || 0 + }; + } + } + + return { base, prerelease }; +} + +/** + * Compare semantic versions with proper pre-release support * Returns: 1 if a > b, -1 if a < b, 0 if equal + * + * Pre-release ordering: + * - alpha < beta < rc < stable (no prerelease) + * - 2.7.2-beta.1 < 2.7.2-beta.2 < 2.7.2 (stable) + * - 2.7.1 < 2.7.2-beta.1 < 2.7.2 */ export function compareVersions(a: string, b: string): number { - const partsA = a.split('.').map(Number); - const partsB = b.split('.').map(Number); - - for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { - const numA = partsA[i] || 0; - const numB = partsB[i] || 0; - + const parsedA = parseVersion(a); + const parsedB = parseVersion(b); + + // Compare base versions first + const maxLen = Math.max(parsedA.base.length, parsedB.base.length); + for (let i = 0; i < maxLen; i++) { + const numA = parsedA.base[i] || 0; + const numB = parsedB.base[i] || 0; + if (numA > numB) return 1; if (numA < numB) return -1; } - + + // Base versions are equal, compare prereleases + // No prerelease = stable = higher than any prerelease of same base + if (!parsedA.prerelease && !parsedB.prerelease) return 0; + if (!parsedA.prerelease && parsedB.prerelease) return 1; // a is stable, b is prerelease + if (parsedA.prerelease && !parsedB.prerelease) return -1; // a is prerelease, b is stable + + // Both have prereleases - compare type then number + const prereleaseOrder: Record = { alpha: 0, beta: 1, rc: 2 }; + const typeA = prereleaseOrder[parsedA.prerelease!.type] ?? 1; + const typeB = prereleaseOrder[parsedB.prerelease!.type] ?? 1; + + if (typeA > typeB) return 1; + if (typeA < typeB) return -1; + + // Same prerelease type, compare numbers + if (parsedA.prerelease!.num > parsedB.prerelease!.num) return 1; + if (parsedA.prerelease!.num < parsedB.prerelease!.num) return -1; + return 0; }