Compare commits

...

2 Commits

Author SHA1 Message Date
bymyself
b92369493a refactor: address PR review comments for semver migration
- Use named imports instead of wildcard imports for semver
- Add memoized computed properties for coerced versions
- Implement explicit Git hash detection function
- Standardize coercion fallback pattern with helper functions
- Create versionUtil.ts with reusable version validation functions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-06 21:19:14 -07:00
bymyself
9da2a5b6de [refactor] replace manual semver operations with semver package
Replace custom compareVersions and isSemVer functions with the robust semver package to handle version comparisons more reliably. This addresses edge cases and follows industry standards for semantic version handling.
2025-08-06 21:19:14 -07:00
10 changed files with 172 additions and 112 deletions

View File

@@ -44,11 +44,12 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import Message from 'primevue/message'
import { rcompare } from 'semver'
import { computed, ref } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { compareVersions } from '@/utils/formatUtil'
import { coerceVersion } from '@/utils/versionUtil'
const props = defineProps<{
missingCoreNodes: Record<string, LGraphNode[]>
@@ -78,7 +79,10 @@ whenever(
const sortedMissingCoreNodes = computed(() => {
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
// Sort by version in descending order (newest first)
return compareVersions(b, a) // Reversed for descending order
const versionA = coerceVersion(a, '')
const versionB = coerceVersion(b, '')
if (!versionA || !versionB) return 0
return rcompare(versionA, versionB)
})
})

View File

@@ -44,7 +44,7 @@ import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import { isSemVer } from '@/utils/formatUtil'
import { isValidSemver } from '@/utils/versionUtil'
const TRUNCATED_HASH_LENGTH = 7
@@ -70,8 +70,9 @@ const installedVersion = computed(() => {
nodePack.latest_version?.version ??
SelectedVersion.NIGHTLY
// If Git hash, truncate to 7 characters
return isSemVer(version) ? version : version.slice(0, TRUNCATED_HASH_LENGTH)
return isValidSemver(version)
? version
: version.slice(0, TRUNCATED_HASH_LENGTH)
})
const toggleVersionSelector = (event: Event) => {

View File

@@ -75,7 +75,7 @@ import {
SelectedVersion
} from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import { isSemVer } from '@/utils/formatUtil'
import { isValidSemver } from '@/utils/versionUtil'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
@@ -95,9 +95,9 @@ const isQueueing = ref(false)
const selectedVersion = ref<string>(SelectedVersion.LATEST)
onMounted(() => {
const initialVersion = getInitialSelectedVersion() ?? SelectedVersion.LATEST
selectedVersion.value =
// Use NIGHTLY when version is a Git hash
isSemVer(initialVersion) ? initialVersion : SelectedVersion.NIGHTLY
selectedVersion.value = isValidSemver(initialVersion)
? initialVersion
: SelectedVersion.NIGHTLY
})
const getInitialSelectedVersion = () => {

View File

@@ -1,8 +1,9 @@
import { gt } from 'semver'
import { computed } from 'vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
import { coerceVersion, isNightlyVersion } from '@/utils/versionUtil'
export const usePackUpdateStatus = (
nodePack: components['schemas']['Node']
@@ -16,14 +17,25 @@ export const usePackUpdateStatus = (
const latestVersion = computed(() => nodePack.latest_version?.version)
const isNightlyPack = computed(
() => !!installedVersion.value && !isSemVer(installedVersion.value)
() => !!installedVersion.value && isNightlyVersion(installedVersion.value)
)
const coercedInstalledVersion = computed(() =>
installedVersion.value ? coerceVersion(installedVersion.value) : null
)
const coercedLatestVersion = computed(() =>
latestVersion.value ? coerceVersion(latestVersion.value) : null
)
const isUpdateAvailable = computed(() => {
if (!isInstalled.value || isNightlyPack.value || !latestVersion.value) {
return false
}
return compareVersions(latestVersion.value, installedVersion.value) > 0
if (!coercedInstalledVersion.value || !coercedLatestVersion.value) {
return false
}
return gt(coercedLatestVersion.value, coercedInstalledVersion.value)
})
return {

View File

@@ -1,11 +1,13 @@
import { defineStore } from 'pinia'
import { eq, gt } from 'semver'
import { computed, ref } from 'vue'
import { type ReleaseNote, useReleaseService } from '@/services/releaseService'
import { useSettingStore } from '@/stores/settingStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { isElectron } from '@/utils/envUtil'
import { compareVersions, stringToLocale } from '@/utils/formatUtil'
import { stringToLocale } from '@/utils/formatUtil'
import { coerceVersion } from '@/utils/versionUtil'
// Store for managing release notes
export const useReleaseStore = defineStore('release', () => {
@@ -19,11 +21,18 @@ export const useReleaseStore = defineStore('release', () => {
const systemStatsStore = useSystemStatsStore()
const settingStore = useSettingStore()
// Current ComfyUI version
const currentComfyUIVersion = computed(
() => systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
)
const coercedCurrentVersion = computed(() =>
coerceVersion(currentComfyUIVersion.value)
)
const coercedRecentReleaseVersion = computed(() =>
recentRelease.value ? coerceVersion(recentRelease.value.version) : '0.0.0'
)
// Release data from settings
const locale = computed(() => settingStore.get('Comfy.Locale'))
const releaseVersion = computed(() =>
@@ -54,16 +63,13 @@ export const useReleaseStore = defineStore('release', () => {
const isNewVersionAvailable = computed(
() =>
!!recentRelease.value &&
compareVersions(
recentRelease.value.version,
currentComfyUIVersion.value
) > 0
gt(coercedRecentReleaseVersion.value, coercedCurrentVersion.value)
)
const isLatestVersion = computed(
() =>
!!recentRelease.value &&
!compareVersions(recentRelease.value.version, currentComfyUIVersion.value)
eq(coercedRecentReleaseVersion.value, coercedCurrentVersion.value)
)
const hasMediumOrHighAttention = computed(() =>

View File

@@ -1,5 +1,6 @@
import _ from 'lodash'
import { defineStore } from 'pinia'
import { gte, rcompare, valid } from 'semver'
import { ref } from 'vue'
import type { Settings } from '@/schemas/apiSchema'
@@ -7,7 +8,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { SettingParams } from '@/types/settingTypes'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
import { coerceVersion } from '@/utils/versionUtil'
export const getSettingInfo = (setting: SettingParams) => {
const parts = setting.category || setting.id.split('.')
@@ -132,17 +133,30 @@ export const useSettingStore = defineStore('setting', () => {
if (installedVersion) {
const sortedVersions = Object.keys(defaultsByInstallVersion).sort(
(a, b) => compareVersions(b, a)
(a, b) => {
const versionA = coerceVersion(a, '')
const versionB = coerceVersion(b, '')
if (!versionA || !versionB) return 0
return rcompare(versionA, versionB)
}
)
for (const version of sortedVersions) {
// Ensure the version is in a valid format before comparing
if (!isSemVer(version)) {
if (!valid(version)) {
continue
}
if (compareVersions(installedVersion, version) >= 0) {
const versionedDefault = defaultsByInstallVersion[version]
const installedSemver = coerceVersion(installedVersion, '')
const targetSemver = coerceVersion(version, '')
if (
installedSemver &&
targetSemver &&
gte(installedSemver, targetSemver)
) {
const versionedDefault =
defaultsByInstallVersion[
version as `${number}.${number}.${number}`
]
return typeof versionedDefault === 'function'
? versionedDefault()
: versionedDefault

View File

@@ -1,10 +1,11 @@
import { useStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import * as semver from 'semver'
import { gt } from 'semver'
import { computed } from 'vue'
import config from '@/config'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { isValidSemver } from '@/utils/versionUtil'
const DISMISSAL_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
@@ -26,13 +27,13 @@ export const useVersionCompatibilityStore = defineStore(
if (
!frontendVersion.value ||
!requiredFrontendVersion.value ||
!semver.valid(frontendVersion.value) ||
!semver.valid(requiredFrontendVersion.value)
!isValidSemver(frontendVersion.value) ||
!isValidSemver(requiredFrontendVersion.value)
) {
return false
}
// Returns true if required version is greater than frontend version
return semver.gt(requiredFrontendVersion.value, frontendVersion.value)
return gt(requiredFrontendVersion.value, frontendVersion.value)
})
const isFrontendNewer = computed(() => {

View File

@@ -390,39 +390,6 @@ export const downloadUrlToHfRepoUrl = (url: string): string => {
}
}
export const isSemVer = (
version: string
): version is `${number}.${number}.${number}` => {
const regex = /^\d+\.\d+\.\d+$/
return regex.test(version)
}
const normalizeVersion = (version: string) =>
version
.split(/[+.-]/)
.map(Number)
.filter((part) => !Number.isNaN(part))
export function compareVersions(
versionA: string | undefined,
versionB: string | undefined
): number {
versionA ??= '0.0.0'
versionB ??= '0.0.0'
const aParts = normalizeVersion(versionA)
const bParts = normalizeVersion(versionB)
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aPart = aParts[i] ?? 0
const bPart = bParts[i] ?? 0
if (aPart < bPart) return -1
if (aPart > bPart) return 1
}
return 0
}
/**
* Converts a currency amount to Metronome's integer representation.
* For USD, converts to cents (multiplied by 100).

52
src/utils/versionUtil.ts Normal file
View File

@@ -0,0 +1,52 @@
import { coerce, valid } from 'semver'
/**
* Validates if a string is a valid semantic version.
* @param version The version string to validate
* @returns true if the version is a valid semver, false otherwise
*/
export function isValidSemver(version: string): boolean {
return !!valid(version)
}
/**
* Checks if a version string is a Git hash (not a semantic version).
* Git hashes are typically 7-40 characters of hexadecimal.
* @param version The version string to check
* @returns true if the version appears to be a Git hash, false otherwise
*/
export function isGitHash(version: string): boolean {
// Check if it's NOT a valid semver and matches git hash pattern
if (isValidSemver(version)) {
return false
}
// Git hash pattern: 7-40 hexadecimal characters
const gitHashPattern = /^[0-9a-f]{7,40}$/i
return gitHashPattern.test(version)
}
/**
* Coerces a version string to a valid semver version with a fallback.
* @param version The version string to coerce
* @param fallback The fallback version if coercion fails (default: '0.0.0')
* @returns A valid semver version string
*/
export function coerceVersion(
version: string | undefined,
fallback = '0.0.0'
): string {
if (!version) return fallback
const coerced = coerce(version)
return coerced ? coerced.version : fallback
}
/**
* Determines if a version string represents a nightly/development version.
* This includes Git hashes and any non-semver version strings.
* @param version The version string to check
* @returns true if the version is a nightly/development version
*/
export function isNightlyVersion(version: string): boolean {
return !isValidSemver(version)
}

View File

@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useReleaseStore } from '@/stores/releaseStore'
// Mock the dependencies
vi.mock('@/utils/formatUtil')
vi.mock('@/utils/envUtil')
vi.mock('@/services/releaseService')
vi.mock('@/stores/settingStore')
@@ -107,18 +106,16 @@ describe('useReleaseStore', () => {
})
it('should show update button (shouldShowUpdateButton)', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1) // newer version available
store.releases = [mockRelease]
// Mock system version to be older than release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.0.0'
store.releases = [mockRelease] // mockRelease has version 1.2.0
expect(store.shouldShowUpdateButton).toBe(true)
})
it('should not show update button when no new version', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(-1) // current version is newer
store.releases = [mockRelease]
// Mock system version to be newer than release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.3.0'
store.releases = [mockRelease] // mockRelease has version 1.2.0
expect(store.shouldShowUpdateButton).toBe(false)
})
})
@@ -137,8 +134,8 @@ describe('useReleaseStore', () => {
})
it('should show toast for medium/high attention releases', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
// Mock system version to be older than release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.0.0'
// Need multiple releases for hasMediumOrHighAttention to work
const mediumRelease = {
@@ -152,16 +149,17 @@ describe('useReleaseStore', () => {
})
it('should show red dot for new versions', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
// Mock system version to be older than release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.0.0'
store.releases = [mockRelease] // mockRelease has version 1.2.0
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', async () => {
// Mock system version to match release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0)
store.releases = [mockRelease] // mockRelease has version 1.2.0
expect(store.shouldShowPopup).toBe(true)
})
@@ -174,7 +172,8 @@ describe('useReleaseStore', () => {
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows'
form_factor: 'git-windows',
locale: 'en'
})
})
})
@@ -188,23 +187,25 @@ describe('useReleaseStore', () => {
})
it('should not show toast even with new version available', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
// Mock system version to be older than release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.0.0'
store.releases = [mockRelease] // mockRelease has version 1.2.0
expect(store.shouldShowToast).toBe(false)
})
it('should not show red dot even with new version available', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
// Mock system version to be older than release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.0.0'
store.releases = [mockRelease] // mockRelease has version 1.2.0
expect(store.shouldShowRedDot).toBe(false)
})
it('should not show popup even for latest version', async () => {
// Mock system version to match release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0)
store.releases = [mockRelease] // mockRelease has version 1.2.0
expect(store.shouldShowPopup).toBe(false)
})
@@ -233,7 +234,8 @@ describe('useReleaseStore', () => {
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows'
form_factor: 'git-windows',
locale: 'en'
})
expect(store.releases).toEqual([mockRelease])
})
@@ -247,7 +249,8 @@ describe('useReleaseStore', () => {
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac'
form_factor: 'desktop-mac',
locale: 'en'
})
})
@@ -373,8 +376,8 @@ describe('useReleaseStore', () => {
return null
})
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
// Mock system version to be older than release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.0.0'
const mediumRelease = { ...mockRelease, attention: 'medium' as const }
store.releases = [
@@ -387,29 +390,27 @@ describe('useReleaseStore', () => {
})
it('should show red dot for new versions', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
store.releases = [mockRelease]
// Mock system version to be older than release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.0.0'
store.releases = [mockRelease] // mockRelease has version 1.2.0
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', async () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0' // Same as release
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0) // versions are equal (latest version)
store.releases = [mockRelease]
// Mock system version to match release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0' // Same as release
store.releases = [mockRelease] // mockRelease has version 1.2.0
expect(store.shouldShowPopup).toBe(true)
})
@@ -465,8 +466,8 @@ describe('useReleaseStore', () => {
})
it('should show toast when conditions are met', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
// Mock system version to be older than release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.0.0'
// Need multiple releases for hasMediumOrHighAttention
const mediumRelease = {
@@ -480,16 +481,17 @@ describe('useReleaseStore', () => {
})
it('should show red dot when new version available', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
// Mock system version to be older than release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.0.0'
store.releases = [mockRelease] // mockRelease has version 1.2.0
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', async () => {
// Mock system version to match release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0)
store.releases = [mockRelease] // mockRelease has version 1.2.0
expect(store.shouldShowPopup).toBe(true)
})
@@ -502,8 +504,8 @@ describe('useReleaseStore', () => {
})
it('should NOT show toast even when all other conditions are met', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
// Mock system version to be older than release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.0.0'
// Set up all conditions that would normally show toast
const mediumRelease = {
@@ -517,15 +519,16 @@ describe('useReleaseStore', () => {
})
it('should NOT show red dot even when new version available', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
// Mock system version to be older than release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.0.0'
store.releases = [mockRelease] // mockRelease has version 1.2.0
expect(store.shouldShowRedDot).toBe(false)
})
it('should NOT show toast regardless of attention level', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
// Mock system version to be older than release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.0.0'
// Test with high attention releases
const highRelease = {
@@ -544,8 +547,8 @@ describe('useReleaseStore', () => {
})
it('should NOT show red dot even with high attention release', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
// Mock system version to be older than release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.0.0'
store.releases = [{ ...mockRelease, attention: 'high' as const }]
@@ -553,9 +556,9 @@ describe('useReleaseStore', () => {
})
it('should NOT show popup even for latest version', async () => {
// Mock system version to match release version
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0)
store.releases = [mockRelease] // mockRelease has version 1.2.0
expect(store.shouldShowPopup).toBe(false)
})