fix(update): compare core versions using SemVer

Only show update indicators when the remote core version is newer.
Add coverage for prereleases, build metadata, invalid versions, and version boundaries.
This commit is contained in:
lzt404 committed 2026-09-06 01:28:58 +08:00
1 parent 355d8057e6
commit 45ecd37ce5
3 files changed
+105 -4

No files matched your search

+56 -3
View File
@@ -35,13 +35,66 @@ function normalizeVersion(version: string | null | undefined) {
return (version ?? '').trim().replace(/^v/i, '');
}
type SemanticVersion = {
core: [string, string, string];
prerelease: string[] | null;
};
const semanticVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
function parseSemanticVersion(version: string | null | undefined): SemanticVersion | null {
const match = semanticVersionPattern.exec(normalizeVersion(version));
if (!match) return null;
return {
core: [match[1], match[2], match[3]],
prerelease: match[4] ? match[4].split('.') : null,
};
}
function compareNumericIdentifier(left: string, right: string) {
if (left.length !== right.length) return left.length > right.length ? 1 : -1;
if (left === right) return 0;
return left > right ? 1 : -1;
}
function compareSemanticVersions(left: SemanticVersion, right: SemanticVersion) {
for (let index = 0; index < left.core.length; index += 1) {
const difference = compareNumericIdentifier(left.core[index], right.core[index]);
if (difference !== 0) return difference;
}
if (left.prerelease === null) return right.prerelease === null ? 0 : 1;
if (right.prerelease === null) return -1;
const count = Math.max(left.prerelease.length, right.prerelease.length);
for (let index = 0; index < count; index += 1) {
const leftIdentifier = left.prerelease[index];
const rightIdentifier = right.prerelease[index];
if (leftIdentifier === undefined) return -1;
if (rightIdentifier === undefined) return 1;
if (leftIdentifier === rightIdentifier) continue;
const leftIsNumeric = /^\d+$/.test(leftIdentifier);
const rightIsNumeric = /^\d+$/.test(rightIdentifier);
if (leftIsNumeric && rightIsNumeric) {
return compareNumericIdentifier(leftIdentifier, rightIdentifier);
}
if (leftIsNumeric) return -1;
if (rightIsNumeric) return 1;
return leftIdentifier > rightIdentifier ? 1 : -1;
}
return 0;
}
export function coreUpdateAvailable(
currentVersion: string | null | undefined,
latestVersion: string | null | undefined,
) {
const current = normalizeVersion(currentVersion);
const latest = normalizeVersion(latestVersion);
return Boolean(current && latest && current !== latest);
const current = parseSemanticVersion(currentVersion);
const latest = parseSemanticVersion(latestVersion);
return Boolean(current && latest && compareSemanticVersions(latest, current) > 0);
}
export function requestLatestCore(force = false) {
+1 -1
View File
@@ -742,7 +742,7 @@ export function VersionManagementPage() {
<button
type="button"
className={latestVersion && (!coreInstalled || currentVersion !== latestVersion) ? 'primary-button' : 'secondary-button'}
className={latestVersion && (!coreInstalled || coreHasUpdate) ? 'primary-button' : 'secondary-button'}
title={latestVersion ? t('kernel.versions.stopAndUpdateVersion', { version: latestVersion }) : t('kernel.versions.installLatest')}
disabled={!latestVersion || busy}
onClick={() => setConfirmUpdateOpen(true)}
+48
View File
@@ -28,7 +28,55 @@ describe('VersionManagement helper functions', () => {
it('compares installed and latest core versions consistently', () => {
expect(coreUpdateAvailable('v6.6.0', '6.6.0')).toBe(false);
expect(coreUpdateAvailable('6.5.0', 'v6.6.0')).toBe(true);
expect(coreUpdateAvailable('v7.2.151', 'v7.2.150')).toBe(false);
expect(coreUpdateAvailable('6.9.0', '6.10.0')).toBe(true);
expect(coreUpdateAvailable('6.10.0', '6.9.0')).toBe(false);
expect(coreUpdateAvailable('6.6.0-rc.2', '6.6.0-rc.10')).toBe(true);
expect(coreUpdateAvailable('6.6.0-rc.1', '6.6.0')).toBe(true);
expect(coreUpdateAvailable('6.6.0', '6.6.0-rc.1')).toBe(false);
expect(coreUpdateAvailable('6.6.0+local', '6.6.0+remote')).toBe(false);
expect(coreUpdateAvailable(null, '6.6.0')).toBe(false);
expect(coreUpdateAvailable('6.5.0', '')).toBe(false);
expect(coreUpdateAvailable('dev', '6.6.0')).toBe(false);
expect(coreUpdateAvailable('6.5.0', 'unknown')).toBe(false);
});
it('does not show an update indicator for an older core release', () => {
const coreHasUpdate = coreUpdateAvailable('v7.2.151', 'v7.2.150');
expect(appUpdateIndicatorState(false, coreHasUpdate, false)).toBeNull();
});
it('follows the semantic version precedence rules', () => {
const orderedVersions = [
'1.0.0-alpha',
'1.0.0-alpha.1',
'1.0.0-alpha.beta',
'1.0.0-beta',
'1.0.0-beta.2',
'1.0.0-beta.11',
'1.0.0-rc.1',
'1.0.0',
'1.0.1',
'1.1.0',
'2.0.0',
];
for (let index = 1; index < orderedVersions.length; index += 1) {
const older = orderedVersions[index - 1];
const newer = orderedVersions[index];
expect(coreUpdateAvailable(older, newer)).toBe(true);
expect(coreUpdateAvailable(newer, older)).toBe(false);
expect(coreUpdateAvailable(newer, newer)).toBe(false);
}
});
it('handles prefixes, whitespace, large identifiers, and invalid versions', () => {
expect(coreUpdateAvailable(' V7.2.150 ', ' v7.2.151 ')).toBe(true);
expect(coreUpdateAvailable('999999999999999999.0.0', '1000000000000000000.0.0')).toBe(true);
expect(coreUpdateAvailable('1.0.0-999999999999999999', '1.0.0-1000000000000000000')).toBe(true);
expect(coreUpdateAvailable('1.0.0+build.1', '1.0.0+build.2')).toBe(false);
expect(coreUpdateAvailable('1.0.0', '1.01.0')).toBe(false);
expect(coreUpdateAvailable('1.0', '1.0.1')).toBe(false);
expect(coreUpdateAvailable('1.0.0.0', '1.0.1')).toBe(false);
});
});