diff --git a/browser_tests/tests/versionMismatchWarnings.spec.ts b/browser_tests/tests/versionMismatchWarnings.spec.ts new file mode 100644 index 0000000000..d85f187238 --- /dev/null +++ b/browser_tests/tests/versionMismatchWarnings.spec.ts @@ -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() + }) +}) diff --git a/package-lock.json b/package-lock.json index fa5cee570b..5ec43f6632 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,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", @@ -62,6 +63,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", @@ -557,6 +559,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", @@ -601,6 +612,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", @@ -622,6 +642,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", @@ -2422,18 +2451,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", @@ -4523,6 +4540,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", @@ -4754,19 +4777,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", @@ -6537,19 +6547,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", @@ -7449,19 +7446,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", @@ -7741,18 +7725,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", @@ -7852,19 +7824,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", @@ -10284,18 +10243,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", @@ -10726,19 +10673,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", @@ -12688,19 +12622,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", @@ -14343,12 +14264,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": { @@ -16207,19 +16130,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", @@ -17039,19 +16949,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", @@ -17104,19 +17001,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", diff --git a/package.json b/package.json index 2dfd9aa87a..b394bc70b9 100644 --- a/package.json +++ b/package.json @@ -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", @@ -105,6 +106,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", diff --git a/src/composables/useFrontendVersionMismatchWarning.ts b/src/composables/useFrontendVersionMismatchWarning.ts new file mode 100644 index 0000000000..11897a0162 --- /dev/null +++ b/src/composables/useFrontendVersionMismatchWarning.ts @@ -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 + ) + } +} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index df92e481bf..5fa4aa4226 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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", @@ -1337,6 +1343,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", diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index 12fbe2981c..f2ad92e972 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -320,6 +320,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() diff --git a/src/stores/README.md b/src/stores/README.md index 3a61d7ae65..de45fdc71a 100644 --- a/src/stores/README.md +++ b/src/stores/README.md @@ -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 | diff --git a/src/stores/versionCompatibilityStore.ts b/src/stores/versionCompatibilityStore.ts new file mode 100644 index 0000000000..da483846e3 --- /dev/null +++ b/src/stores/versionCompatibilityStore.ts @@ -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, + localStorage, + { + serializer: { + read: (value: string) => { + try { + return JSON.parse(value) + } catch { + return {} + } + }, + write: (value: Record) => 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 + } + } +) diff --git a/src/views/GraphView.vue b/src/views/GraphView.vue index f21cf1b617..f5e2992944 100644 --- a/src/views/GraphView.vue +++ b/src/views/GraphView.vue @@ -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( () => { diff --git a/tests-ui/tests/composables/useFrontendVersionMismatchWarning.test.ts b/tests-ui/tests/composables/useFrontendVersionMismatchWarning.test.ts new file mode 100644 index 0000000000..b8b4fceac7 --- /dev/null +++ b/tests-ui/tests/composables/useFrontendVersionMismatchWarning.test.ts @@ -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() + 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) + }) +}) diff --git a/tests-ui/tests/store/systemStatsStore.test.ts b/tests-ui/tests/store/systemStatsStore.test.ts index 3376a19c07..84e84ec44a 100644 --- a/tests-ui/tests/store/systemStatsStore.test.ts +++ b/tests-ui/tests/store/systemStatsStore.test.ts @@ -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', () => { diff --git a/tests-ui/tests/store/versionCompatibilityStore.test.ts b/tests-ui/tests/store/versionCompatibilityStore.test.ts new file mode 100644 index 0000000000..e3d3ceca99 --- /dev/null +++ b/tests-ui/tests/store/versionCompatibilityStore.test.ts @@ -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) +vi.mock('@vueuse/core', () => ({ + useStorage: vi.fn(() => mockDismissalStorage) +})) + +describe('useVersionCompatibilityStore', () => { + let store: ReturnType + 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() + }) + }) +})