Feature Implemented: Warning displayed when frontend version mismatches (#4363)

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
SHIVANSH GUPTA
2025-07-29 06:53:49 +05:30
committed by GitHub
parent b1436a068b
commit 577cd23c3e
12 changed files with 1012 additions and 158 deletions

View File

@@ -0,0 +1,117 @@
import { expect } from '@playwright/test'
import { SystemStats } from '../../src/schemas/apiSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Version Mismatch Warnings', () => {
const ALWAYS_AHEAD_OF_INSTALLED_VERSION = '100.100.100'
const ALWAYS_BEHIND_INSTALLED_VERSION = '0.0.0'
const createMockSystemStatsRes = (
requiredFrontendVersion: string
): SystemStats => {
return {
system: {
os: 'posix',
ram_total: 67235385344,
ram_free: 13464207360,
comfyui_version: '0.3.46',
required_frontend_version: requiredFrontendVersion,
python_version: '3.12.3 (main, Jun 18 2025, 17:59:45) [GCC 13.3.0]',
pytorch_version: '2.6.0+cu124',
embedded_python: false,
argv: ['main.py']
},
devices: [
{
name: 'cuda:0 NVIDIA GeForce RTX 4070 : cudaMallocAsync',
type: 'cuda',
index: 0,
vram_total: 12557156352,
vram_free: 2439249920,
torch_vram_total: 0,
torch_vram_free: 0
}
]
}
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('should show version mismatch warnings when installed version lower than required', async ({
comfyPage
}) => {
// Mock system_stats route to indicate that the installed version is always ahead of the required version
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
createMockSystemStatsRes(ALWAYS_AHEAD_OF_INSTALLED_VERSION)
)
})
})
await comfyPage.setup()
// Expect a warning toast to be shown
await expect(
comfyPage.page.getByText('Version Compatibility Warning')
).toBeVisible()
})
test('should not show version mismatch warnings when installed version is ahead of required', async ({
comfyPage
}) => {
// Mock system_stats route to indicate that the installed version is always ahead of the required version
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
createMockSystemStatsRes(ALWAYS_BEHIND_INSTALLED_VERSION)
)
})
})
await comfyPage.setup()
// Expect no warning toast to be shown
await expect(
comfyPage.page.getByText('Version Compatibility Warning')
).not.toBeVisible()
})
test('should persist dismissed state across sessions', async ({
comfyPage
}) => {
// Mock system_stats route to indicate that the installed version is always ahead of the required version
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
createMockSystemStatsRes(ALWAYS_AHEAD_OF_INSTALLED_VERSION)
)
})
})
await comfyPage.setup()
// Locate the warning toast and dismiss it
const warningToast = comfyPage.page
.locator('div')
.filter({ hasText: 'Version Compatibility' })
.nth(3)
await warningToast.waitFor({ state: 'visible' })
const dismissButton = warningToast.getByRole('button', { name: 'Close' })
await dismissButton.click()
// Reload the page, keeping local storage
await comfyPage.setup({ clearStorage: false })
// The same warning from same versions should not be shown to the user again
await expect(
comfyPage.page.getByText('Version Compatibility Warning')
).not.toBeVisible()
})
})

198
package-lock.json generated
View File

@@ -42,6 +42,7 @@
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"semver": "^7.7.2",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.5.13",
@@ -64,6 +65,7 @@
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
"@types/semver": "^7.7.0",
"@types/three": "^0.169.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/test-utils": "^2.4.6",
@@ -559,6 +561,15 @@
"url": "https://opencollective.com/babel"
}
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/generator": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz",
@@ -603,6 +614,15 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz",
@@ -624,6 +644,15 @@
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz",
@@ -2421,18 +2450,6 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/synckit": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.3.tgz",
@@ -4522,6 +4539,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/semver": {
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz",
"integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==",
"dev": true
},
"node_modules/@types/stats.js": {
"version": "0.17.3",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz",
@@ -4753,19 +4776,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.0.tgz",
@@ -6561,19 +6571,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/conf/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/confbox": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz",
@@ -7473,19 +7470,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/editorconfig/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -7765,18 +7749,6 @@
"eslint": ">=6.0.0"
}
},
"node_modules/eslint-compat-utils/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/eslint-config-prettier": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.2.tgz",
@@ -7876,19 +7848,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-vue/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/eslint-plugin-vue/node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
@@ -10371,18 +10330,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/jsonc-eslint-parser/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jsondiffpatch": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz",
@@ -10813,19 +10760,6 @@
"node": ">=14"
}
},
"node_modules/langsmith/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/latest-version": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz",
@@ -12825,19 +12759,6 @@
"integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==",
"dev": true
},
"node_modules/package-json/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/package-manager-detector": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.0.tgz",
@@ -14507,12 +14428,14 @@
"dev": true
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
@@ -16393,19 +16316,6 @@
"url": "https://github.com/yeoman/update-notifier?sponsor=1"
}
},
"node_modules/update-notifier/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -17225,19 +17135,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vue-eslint-parser/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/vue-i18n": {
"version": "9.14.3",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.3.tgz",
@@ -17290,19 +17187,6 @@
"typescript": ">=5.0.0"
}
},
"node_modules/vue-tsc/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/vuefire": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/vuefire/-/vuefire-3.2.1.tgz",

View File

@@ -40,6 +40,7 @@
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
"@types/semver": "^7.7.0",
"@types/three": "^0.169.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/test-utils": "^2.4.6",
@@ -107,6 +108,7 @@
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"semver": "^7.7.2",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.5.13",

View File

@@ -0,0 +1,94 @@
import { whenever } from '@vueuse/core'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toastStore'
import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore'
export interface UseFrontendVersionMismatchWarningOptions {
immediate?: boolean
}
/**
* Composable for handling frontend version mismatch warnings.
*
* Displays toast notifications when the frontend version is incompatible with the backend,
* either because the frontend is outdated or newer than the backend expects.
* Automatically dismisses warnings when shown and persists dismissal state for 7 days.
*
* @param options - Configuration options
* @param options.immediate - If true, automatically shows warning when version mismatch is detected
* @returns Object with methods and computed properties for managing version warnings
*
* @example
* ```ts
* // Show warning immediately when mismatch detected
* const { showWarning, shouldShowWarning } = useFrontendVersionMismatchWarning({ immediate: true })
*
* // Manual control
* const { showWarning } = useFrontendVersionMismatchWarning()
* showWarning() // Call when needed
* ```
*/
export function useFrontendVersionMismatchWarning(
options: UseFrontendVersionMismatchWarningOptions = {}
) {
const { immediate = false } = options
const { t } = useI18n()
const toastStore = useToastStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
// Track if we've already shown the warning
let hasShownWarning = false
const showWarning = () => {
// Prevent showing the warning multiple times
if (hasShownWarning) return
const message = versionCompatibilityStore.warningMessage
if (!message) return
const detailMessage = t('g.frontendOutdated', {
frontendVersion: message.frontendVersion,
requiredVersion: message.requiredVersion
})
const fullMessage = t('g.versionMismatchWarningMessage', {
warning: t('g.versionMismatchWarning'),
detail: detailMessage
})
toastStore.addAlert(fullMessage)
hasShownWarning = true
// Automatically dismiss the warning so it won't show again for 7 days
versionCompatibilityStore.dismissWarning()
}
onMounted(() => {
// Only set up the watcher if immediate is true
if (immediate) {
whenever(
() => versionCompatibilityStore.shouldShowWarning,
() => {
showWarning()
},
{
immediate: true,
once: true
}
)
}
})
return {
showWarning,
shouldShowWarning: computed(
() => versionCompatibilityStore.shouldShowWarning
),
dismissWarning: versionCompatibilityStore.dismissWarning,
hasVersionMismatch: computed(
() => versionCompatibilityStore.hasVersionMismatch
)
}
}

View File

@@ -98,6 +98,12 @@
"nodes": "Nodes",
"community": "Community",
"all": "All",
"versionMismatchWarning": "Version Compatibility Warning",
"versionMismatchWarningMessage": "{warning}: {detail} Visit https://docs.comfy.org/installation/update_comfyui#common-update-issues for update instructions.",
"frontendOutdated": "Frontend version {frontendVersion} is outdated. Backend requires {requiredVersion} or higher.",
"frontendNewer": "Frontend version {frontendVersion} may not be compatible with backend version {backendVersion}.",
"updateFrontend": "Update Frontend",
"dismiss": "Dismiss",
"update": "Update",
"updated": "Updated",
"resultsCount": "Found {count} Results",
@@ -1351,6 +1357,13 @@
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
"coreNodesFromVersion": "Requires ComfyUI {version}:"
},
"versionMismatchWarning": {
"title": "Version Compatibility Warning",
"frontendOutdated": "Frontend version {frontendVersion} is outdated. Backend requires version {requiredVersion} or higher.",
"frontendNewer": "Frontend version {frontendVersion} may not be compatible with backend version {backendVersion}.",
"updateFrontend": "Update Frontend",
"dismiss": "Dismiss"
},
"errorDialog": {
"defaultTitle": "An error occurred",
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",

View File

@@ -338,6 +338,7 @@ export const zSystemStats = z.object({
embedded_python: z.boolean(),
comfyui_version: z.string(),
pytorch_version: z.string(),
required_frontend_version: z.string().optional(),
argv: z.array(z.string()),
ram_total: z.number(),
ram_free: z.number()

View File

@@ -135,6 +135,7 @@ The following table lists ALL stores in the system as of 2025-01-30:
| toastStore.ts | Manages toast notifications | UI |
| userFileStore.ts | Manages user file operations | Files |
| userStore.ts | Manages user data and preferences | User |
| versionCompatibilityStore.ts | Manages frontend/backend version compatibility warnings | Core |
| widgetStore.ts | Manages widget configurations | Widgets |
| workflowStore.ts | Handles workflow data and operations | Workflows |
| workflowTemplatesStore.ts | Manages workflow templates | Workflows |

View File

@@ -0,0 +1,138 @@
import { useStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import * as semver from 'semver'
import { computed } from 'vue'
import config from '@/config'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
const DISMISSAL_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export const useVersionCompatibilityStore = defineStore(
'versionCompatibility',
() => {
const systemStatsStore = useSystemStatsStore()
const frontendVersion = computed(() => config.app_version)
const backendVersion = computed(
() => systemStatsStore.systemStats?.system?.comfyui_version ?? ''
)
const requiredFrontendVersion = computed(
() =>
systemStatsStore.systemStats?.system?.required_frontend_version ?? ''
)
const isFrontendOutdated = computed(() => {
if (
!frontendVersion.value ||
!requiredFrontendVersion.value ||
!semver.valid(frontendVersion.value) ||
!semver.valid(requiredFrontendVersion.value)
) {
return false
}
// Returns true if required version is greater than frontend version
return semver.gt(requiredFrontendVersion.value, frontendVersion.value)
})
const isFrontendNewer = computed(() => {
// We don't warn about frontend being newer than backend
// Only warn when frontend is outdated (behind required version)
return false
})
const hasVersionMismatch = computed(() => {
return isFrontendOutdated.value
})
const versionKey = computed(() => {
if (
!frontendVersion.value ||
!backendVersion.value ||
!requiredFrontendVersion.value
) {
return null
}
return `${frontendVersion.value}-${backendVersion.value}-${requiredFrontendVersion.value}`
})
// Use reactive storage for dismissals - creates a reactive ref that syncs with localStorage
// All version mismatch dismissals are stored in a single object for clean localStorage organization
const dismissalStorage = useStorage(
'comfy.versionMismatch.dismissals',
{} as Record<string, number>,
localStorage,
{
serializer: {
read: (value: string) => {
try {
return JSON.parse(value)
} catch {
return {}
}
},
write: (value: Record<string, number>) => JSON.stringify(value)
}
}
)
const isDismissed = computed(() => {
if (!versionKey.value) return false
const dismissedUntil = dismissalStorage.value[versionKey.value]
if (!dismissedUntil) return false
// Check if dismissal has expired
return Date.now() < dismissedUntil
})
const shouldShowWarning = computed(() => {
return hasVersionMismatch.value && !isDismissed.value
})
const warningMessage = computed(() => {
if (isFrontendOutdated.value) {
return {
type: 'outdated' as const,
frontendVersion: frontendVersion.value,
requiredVersion: requiredFrontendVersion.value
}
}
return null
})
async function checkVersionCompatibility() {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
}
function dismissWarning() {
if (!versionKey.value) return
const dismissUntil = Date.now() + DISMISSAL_DURATION_MS
dismissalStorage.value = {
...dismissalStorage.value,
[versionKey.value]: dismissUntil
}
}
async function initialize() {
await checkVersionCompatibility()
}
return {
frontendVersion,
backendVersion,
requiredFrontendVersion,
hasVersionMismatch,
shouldShowWarning,
warningMessage,
isFrontendOutdated,
isFrontendNewer,
checkVersionCompatibility,
dismissWarning,
initialize
}
}
)

View File

@@ -23,7 +23,14 @@
import { useBreakpoints, useEventListener } from '@vueuse/core'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { computed, onBeforeUnmount, onMounted, watch, watchEffect } from 'vue'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
watch,
watchEffect
} from 'vue'
import { useI18n } from 'vue-i18n'
import MenuHamburger from '@/components/MenuHamburger.vue'
@@ -35,6 +42,7 @@ import TopMenubar from '@/components/topbar/TopMenubar.vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFrontendVersionMismatchWarning } from '@/composables/useFrontendVersionMismatchWarning'
import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
import { i18n } from '@/i18n'
@@ -54,6 +62,7 @@ import {
} from '@/stores/queueStore'
import { useServerConfigStore } from '@/stores/serverConfigStore'
import { useSettingStore } from '@/stores/settingStore'
import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -70,6 +79,8 @@ const settingStore = useSettingStore()
const executionStore = useExecutionStore()
const colorPaletteStore = useColorPaletteStore()
const queueStore = useQueueStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
const breakpoints = useBreakpoints({ md: 961 })
const isMobile = breakpoints.smaller('md')
const showTopMenu = computed(() => isMobile.value || useNewMenu.value === 'Top')
@@ -224,6 +235,17 @@ onBeforeUnmount(() => {
useEventListener(window, 'keydown', useKeybindingService().keybindHandler)
const { wrapWithErrorHandling, wrapWithErrorHandlingAsync } = useErrorHandling()
// Initialize version mismatch warning in setup context
// It will be triggered automatically when the store is ready
useFrontendVersionMismatchWarning({ immediate: true })
void nextTick(() => {
versionCompatibilityStore.initialize().catch((error) => {
console.warn('Version compatibility check failed:', error)
})
})
const onGraphReady = () => {
requestIdleCallback(
() => {

View File

@@ -0,0 +1,234 @@
import { createPinia, setActivePinia } from 'pinia'
import { vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { useFrontendVersionMismatchWarning } from '@/composables/useFrontendVersionMismatchWarning'
import { useToastStore } from '@/stores/toastStore'
import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore'
// Mock globals
//@ts-expect-error Define global for the test
global.__COMFYUI_FRONTEND_VERSION__ = '1.0.0'
// Mock config first - this needs to be before any imports
vi.mock('@/config', () => ({
default: {
app_title: 'ComfyUI',
app_version: '1.0.0'
}
}))
// Mock app
vi.mock('@/scripts/app', () => ({
app: {
ui: {
settings: {
dispatchChange: vi.fn()
}
}
}
}))
// Mock api
vi.mock('@/scripts/api', () => ({
api: {
getSettings: vi.fn(() => Promise.resolve({})),
storeSetting: vi.fn(() => Promise.resolve(undefined))
}
}))
// Mock vue-i18n
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: any) => {
if (key === 'g.versionMismatchWarning')
return 'Version Compatibility Warning'
if (key === 'g.versionMismatchWarningMessage' && params) {
return `${params.warning}: ${params.detail} Visit https://docs.comfy.org/installation/update_comfyui#common-update-issues for update instructions.`
}
if (key === 'g.frontendOutdated' && params) {
return `Frontend version ${params.frontendVersion} is outdated. Backend requires ${params.requiredVersion} or higher.`
}
if (key === 'g.frontendNewer' && params) {
return `Frontend version ${params.frontendVersion} may not be compatible with backend version ${params.backendVersion}.`
}
return key
}
}),
createI18n: vi.fn(() => ({
global: {
locale: { value: 'en' },
t: vi.fn()
}
}))
}))
// Mock lifecycle hooks to track their calls
const mockOnMounted = vi.fn()
vi.mock('vue', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue')>()
return {
...actual,
onMounted: (fn: () => void) => {
mockOnMounted()
fn()
}
}
})
describe('useFrontendVersionMismatchWarning', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should not show warning when there is no version mismatch', () => {
const toastStore = useToastStore()
const versionStore = useVersionCompatibilityStore()
const addAlertSpy = vi.spyOn(toastStore, 'addAlert')
// Mock no version mismatch
vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(false)
useFrontendVersionMismatchWarning()
expect(addAlertSpy).not.toHaveBeenCalled()
})
it('should show warning immediately when immediate option is true and there is a mismatch', async () => {
const toastStore = useToastStore()
const versionStore = useVersionCompatibilityStore()
const addAlertSpy = vi.spyOn(toastStore, 'addAlert')
const dismissWarningSpy = vi.spyOn(versionStore, 'dismissWarning')
// Mock version mismatch
vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(true)
vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({
type: 'outdated',
frontendVersion: '1.0.0',
requiredVersion: '2.0.0'
})
useFrontendVersionMismatchWarning({ immediate: true })
// For immediate: true, the watcher should fire immediately in onMounted
await nextTick()
expect(addAlertSpy).toHaveBeenCalledWith(
expect.stringContaining('Version Compatibility Warning')
)
expect(addAlertSpy).toHaveBeenCalledWith(
expect.stringContaining('Frontend version 1.0.0 is outdated')
)
// Should automatically dismiss the warning
expect(dismissWarningSpy).toHaveBeenCalled()
})
it('should not show warning immediately when immediate option is false', async () => {
const toastStore = useToastStore()
const versionStore = useVersionCompatibilityStore()
const addAlertSpy = vi.spyOn(toastStore, 'addAlert')
// Mock version mismatch
vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(true)
vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({
type: 'outdated',
frontendVersion: '1.0.0',
requiredVersion: '2.0.0'
})
const result = useFrontendVersionMismatchWarning({ immediate: false })
await nextTick()
// Should not show automatically
expect(addAlertSpy).not.toHaveBeenCalled()
// But should show when called manually
result.showWarning()
expect(addAlertSpy).toHaveBeenCalledOnce()
})
it('should call showWarning method manually', () => {
const toastStore = useToastStore()
const versionStore = useVersionCompatibilityStore()
const addAlertSpy = vi.spyOn(toastStore, 'addAlert')
const dismissWarningSpy = vi.spyOn(versionStore, 'dismissWarning')
vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({
type: 'outdated',
frontendVersion: '1.0.0',
requiredVersion: '2.0.0'
})
const { showWarning } = useFrontendVersionMismatchWarning()
showWarning()
expect(addAlertSpy).toHaveBeenCalledOnce()
expect(dismissWarningSpy).toHaveBeenCalled()
})
it('should expose store methods and computed values', () => {
const versionStore = useVersionCompatibilityStore()
const mockDismissWarning = vi.fn()
vi.spyOn(versionStore, 'dismissWarning').mockImplementation(
mockDismissWarning
)
vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(true)
vi.spyOn(versionStore, 'hasVersionMismatch', 'get').mockReturnValue(true)
const result = useFrontendVersionMismatchWarning()
expect(result.shouldShowWarning.value).toBe(true)
expect(result.hasVersionMismatch.value).toBe(true)
void result.dismissWarning()
expect(mockDismissWarning).toHaveBeenCalled()
})
it('should register onMounted hook', () => {
useFrontendVersionMismatchWarning()
expect(mockOnMounted).toHaveBeenCalledOnce()
})
it('should not show warning when warningMessage is null', () => {
const toastStore = useToastStore()
const versionStore = useVersionCompatibilityStore()
const addAlertSpy = vi.spyOn(toastStore, 'addAlert')
vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue(null)
const { showWarning } = useFrontendVersionMismatchWarning()
showWarning()
expect(addAlertSpy).not.toHaveBeenCalled()
})
it('should only show warning once even if called multiple times', () => {
const toastStore = useToastStore()
const versionStore = useVersionCompatibilityStore()
const addAlertSpy = vi.spyOn(toastStore, 'addAlert')
vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({
type: 'outdated',
frontendVersion: '1.0.0',
requiredVersion: '2.0.0'
})
const { showWarning } = useFrontendVersionMismatchWarning()
// Call showWarning multiple times
showWarning()
showWarning()
showWarning()
// Should only have been called once
expect(addAlertSpy).toHaveBeenCalledTimes(1)
})
})

View File

@@ -41,6 +41,7 @@ describe('useSystemStatsStore', () => {
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
required_frontend_version: '1.24.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
@@ -92,6 +93,32 @@ describe('useSystemStatsStore', () => {
expect(store.isLoading).toBe(false)
})
it('should handle system stats updates', async () => {
const updatedStats = {
system: {
os: 'Windows',
python_version: '3.11.0',
embedded_python: false,
comfyui_version: '1.1.0',
pytorch_version: '2.1.0',
required_frontend_version: '1.25.0',
argv: [],
ram_total: 16000000000,
ram_free: 7000000000
},
devices: []
}
vi.mocked(api.getSystemStats).mockResolvedValue(updatedStats)
await store.fetchSystemStats()
expect(store.systemStats).toEqual(updatedStats)
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
expect(api.getSystemStats).toHaveBeenCalled()
})
})
describe('getFormFactor', () => {

View File

@@ -0,0 +1,321 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore'
vi.mock('@/config', () => ({
default: {
app_version: '1.24.0'
}
}))
vi.mock('@/stores/systemStatsStore')
// Mock useStorage from VueUse
const mockDismissalStorage = ref({} as Record<string, number>)
vi.mock('@vueuse/core', () => ({
useStorage: vi.fn(() => mockDismissalStorage)
}))
describe('useVersionCompatibilityStore', () => {
let store: ReturnType<typeof useVersionCompatibilityStore>
let mockSystemStatsStore: any
beforeEach(() => {
setActivePinia(createPinia())
// Clear the mock dismissal storage
mockDismissalStorage.value = {}
mockSystemStatsStore = {
systemStats: null,
fetchSystemStats: vi.fn()
}
vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore)
store = useVersionCompatibilityStore()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('version compatibility detection', () => {
it('should detect frontend is outdated when required version is higher', async () => {
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.25.0',
required_frontend_version: '1.25.0'
}
}
await store.checkVersionCompatibility()
expect(store.isFrontendOutdated).toBe(true)
expect(store.isFrontendNewer).toBe(false)
expect(store.hasVersionMismatch).toBe(true)
})
it('should not warn when frontend is newer than backend', async () => {
// Frontend: 1.24.0, Backend: 1.23.0, Required: 1.23.0
// Frontend meets required version, no warning needed
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.23.0',
required_frontend_version: '1.23.0'
}
}
await store.checkVersionCompatibility()
expect(store.isFrontendOutdated).toBe(false)
expect(store.isFrontendNewer).toBe(false)
expect(store.hasVersionMismatch).toBe(false)
})
it('should not detect mismatch when versions are compatible', async () => {
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.24.0',
required_frontend_version: '1.24.0'
}
}
await store.checkVersionCompatibility()
expect(store.isFrontendOutdated).toBe(false)
expect(store.isFrontendNewer).toBe(false)
expect(store.hasVersionMismatch).toBe(false)
})
it('should handle missing version information gracefully', async () => {
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '',
required_frontend_version: ''
}
}
await store.checkVersionCompatibility()
expect(store.isFrontendOutdated).toBe(false)
expect(store.isFrontendNewer).toBe(false)
expect(store.hasVersionMismatch).toBe(false)
})
it('should not detect mismatch when versions are not valid semver', async () => {
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '080e6d4af809a46852d1c4b7ed85f06e8a3a72be', // git hash
required_frontend_version: 'not-a-version' // invalid semver format
}
}
await store.checkVersionCompatibility()
expect(store.isFrontendOutdated).toBe(false)
expect(store.isFrontendNewer).toBe(false)
expect(store.hasVersionMismatch).toBe(false)
})
it('should not warn when frontend exceeds required version', async () => {
// Frontend: 1.24.0 (from mock config)
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.22.0', // Backend is older
required_frontend_version: '1.23.0' // Required is 1.23.0, frontend 1.24.0 meets this
}
}
await store.checkVersionCompatibility()
expect(store.isFrontendOutdated).toBe(false) // Frontend 1.24.0 >= Required 1.23.0
expect(store.isFrontendNewer).toBe(false) // Never warns about being newer
expect(store.hasVersionMismatch).toBe(false)
})
})
describe('warning display logic', () => {
it('should show warning when there is a version mismatch and not dismissed', async () => {
// No dismissals in storage
mockDismissalStorage.value = {}
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.25.0',
required_frontend_version: '1.25.0'
}
}
await store.checkVersionCompatibility()
expect(store.shouldShowWarning).toBe(true)
})
it('should not show warning when dismissed', async () => {
const futureTime = Date.now() + 1000000
// Set dismissal in reactive storage
mockDismissalStorage.value = {
'1.24.0-1.25.0-1.25.0': futureTime
}
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.25.0',
required_frontend_version: '1.25.0'
}
}
await store.checkVersionCompatibility()
expect(store.shouldShowWarning).toBe(false)
})
it('should not show warning when no version mismatch', async () => {
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.24.0',
required_frontend_version: '1.24.0'
}
}
await store.checkVersionCompatibility()
expect(store.shouldShowWarning).toBe(false)
})
})
describe('warning messages', () => {
it('should generate outdated message when frontend is outdated', async () => {
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.25.0',
required_frontend_version: '1.25.0'
}
}
await store.checkVersionCompatibility()
expect(store.warningMessage).toEqual({
type: 'outdated',
frontendVersion: '1.24.0',
requiredVersion: '1.25.0'
})
})
it('should return null when no mismatch', async () => {
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.24.0',
required_frontend_version: '1.24.0'
}
}
await store.checkVersionCompatibility()
expect(store.warningMessage).toBeNull()
})
})
describe('dismissal persistence', () => {
it('should save dismissal to reactive storage with expiration', async () => {
const mockNow = 1000000
vi.spyOn(Date, 'now').mockReturnValue(mockNow)
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.25.0',
required_frontend_version: '1.25.0'
}
}
await store.checkVersionCompatibility()
store.dismissWarning()
// Check that the dismissal was added to reactive storage
expect(mockDismissalStorage.value).toEqual({
'1.24.0-1.25.0-1.25.0': mockNow + 7 * 24 * 60 * 60 * 1000
})
})
it('should check dismissal state from reactive storage', async () => {
const futureTime = Date.now() + 1000000 // Still valid
mockDismissalStorage.value = {
'1.24.0-1.25.0-1.25.0': futureTime
}
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.25.0',
required_frontend_version: '1.25.0'
}
}
await store.initialize()
expect(store.shouldShowWarning).toBe(false)
})
it('should show warning if dismissal has expired', async () => {
const pastTime = Date.now() - 1000 // Expired
mockDismissalStorage.value = {
'1.24.0-1.25.0-1.25.0': pastTime
}
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.25.0',
required_frontend_version: '1.25.0'
}
}
await store.initialize()
expect(store.shouldShowWarning).toBe(true)
})
it('should show warning for different version combinations even if previous was dismissed', async () => {
const futureTime = Date.now() + 1000000
// Dismissed for different version combination (1.25.0) but current is 1.26.0
mockDismissalStorage.value = {
'1.24.0-1.25.0-1.25.0': futureTime // Different version was dismissed
}
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.26.0',
required_frontend_version: '1.26.0'
}
}
await store.initialize()
expect(store.shouldShowWarning).toBe(true)
})
})
describe('initialization', () => {
it('should fetch system stats if not available', async () => {
mockSystemStatsStore.systemStats = null
await store.initialize()
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
})
it('should not fetch system stats if already available', async () => {
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.24.0',
required_frontend_version: '1.24.0'
}
}
await store.initialize()
expect(mockSystemStatsStore.fetchSystemStats).not.toHaveBeenCalled()
})
})
})