Compare commits

...

5 Commits

Author SHA1 Message Date
Jedrzej Kosinski
af99419723 test: cover initialize flag-gate and align e2e with default-off updates 2026-07-08 19:10:01 -07:00
Jedrzej Kosinski
39b66c06d0 Merge remote-tracking branch 'origin/main' into feature/show-version-updates-flag
# Conflicts:
#	src/composables/useFeatureFlags.ts
2026-07-08 18:48:42 -07:00
Jedrzej Kosinski
57c74f46c9 fix: gate version-update notifications on setting, default off for local installs
Amp-Thread-ID: https://ampcode.com/threads/T-019f0347-dc00-723a-a4d2-558539329035
Co-authored-by: Amp <amp@ampcode.com>
2026-06-26 15:13:00 -07:00
Jedrzej Kosinski
df7eaab505 fix: wait for server feature flags before fetching releases
Gates release fetching in releaseStore.initialize() on the server feature flags arriving (non-empty), with a 3s timeout fallback, so a host that set show_version_updates=false isn't briefly treated as enabled on first load before negotiation completes. Also replaces the change-detector setting test with behavioral coverage.

Amp-Thread-ID: https://ampcode.com/threads/T-019f0347-dc00-723a-a4d2-558539329035
Co-authored-by: Amp <amp@ampcode.com>
2026-06-26 03:23:09 -07:00
Jedrzej Kosinski
9198f13636 feat: drive ShowVersionUpdates default from server feature flag
The Comfy.Notification.ShowVersionUpdates default now reads the show_version_updates server feature flag (defaulting to true), letting launchers like ComfyUI Desktop change whether new-release notifications show by default. The user setting still overrides it.

Amp-Thread-ID: https://ampcode.com/threads/T-019f0347-dc00-723a-a4d2-558539329035
Co-authored-by: Amp <amp@ampcode.com>
2026-06-26 02:55:33 -07:00
8 changed files with 269 additions and 116 deletions

View File

@@ -138,6 +138,10 @@ test.describe('Help Center', () => {
)
await helpCenter.mockReleases(releases)
await comfyPage.settings.setSetting(
'Comfy.Notification.ShowVersionUpdates',
true
)
await comfyPage.setup({ mockReleases: false })
await helpCenter.open()
@@ -157,6 +161,10 @@ test.describe('Help Center', () => {
await helpCenter.mockReleases([release])
await helpCenter.stubDocsPage()
await comfyPage.settings.setSetting(
'Comfy.Notification.ShowVersionUpdates',
true
)
await comfyPage.setup({ mockReleases: false })
await helpCenter.open()

View File

@@ -22,6 +22,12 @@ test.describe('Release Notifications', () => {
test('should show help center with release information', async ({
comfyPage
}) => {
// Version-update notifications default off on local installs
await comfyPage.settings.setSetting(
'Comfy.Notification.ShowVersionUpdates',
true
)
// Mock release API with test data instead of empty array
await comfyPage.page.route('**/releases**', async (route) => {
const url = route.request().url()
@@ -72,10 +78,10 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeHidden()
})
test('should not show release notifications when mocked (default behavior)', async ({
test('should hide "What\'s New" section by default on local installs', async ({
comfyPage
}) => {
// Use default setup (mockReleases: true)
// Use default setup (mockReleases: true); notifications default off locally
await comfyPage.setup()
// Open help center
@@ -87,16 +93,11 @@ test.describe('Release Notifications', () => {
const helpMenu = comfyPage.page.locator('.help-center-menu')
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section shows no releases
// "What's New?" section is hidden because the setting defaults off
const whatsNewSection = comfyPage.page.getByTestId(
TestIds.dialogs.whatsNewSection
)
await expect(whatsNewSection).toBeVisible()
// Should show "No recent releases" message
await expect(
whatsNewSection.locator('text=No recent releases')
).toBeVisible()
await expect(whatsNewSection).toBeHidden()
// Should not show any popups or toasts
await expect(comfyPage.page.locator('.whats-new-popup')).toBeHidden()
@@ -106,6 +107,12 @@ test.describe('Release Notifications', () => {
})
test('should handle release API errors gracefully', async ({ comfyPage }) => {
// Version-update notifications default off on local installs
await comfyPage.settings.setSetting(
'Comfy.Notification.ShowVersionUpdates',
true
)
// Mock API to return an error
await comfyPage.page.route('**/releases**', async (route) => {
const url = route.request().url()

View File

@@ -33,7 +33,8 @@ export enum ServerFeatureFlag {
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
SIGNUP_TURNSTILE = 'signup_turnstile',
SHOW_VERSION_UPDATES = 'show_version_updates'
}
/**

View File

@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { CORE_SETTINGS } from './coreSettings'
const mockDist = vi.hoisted(() => ({ isCloud: false }))
vi.mock('@/platform/distribution/types', () => ({
isDesktop: false,
isNightly: false,
get isCloud() {
return mockDist.isCloud
}
}))
function getDefault(id: string) {
const setting = CORE_SETTINGS.find((s) => s.id === id)
if (!setting) throw new Error(`Setting ${id} not found`)
const { defaultValue } = setting
return typeof defaultValue === 'function' ? defaultValue() : defaultValue
}
describe('Comfy.Notification.ShowVersionUpdates default', () => {
beforeEach(() => {
mockDist.isCloud = false
})
afterEach(() => {
vi.restoreAllMocks()
})
it('resolves to the show_version_updates server flag value', () => {
vi.spyOn(api, 'getServerFeature').mockReturnValue(true)
expect(getDefault('Comfy.Notification.ShowVersionUpdates')).toBe(true)
vi.spyOn(api, 'getServerFeature').mockReturnValue(false)
expect(getDefault('Comfy.Notification.ShowVersionUpdates')).toBe(false)
})
it('falls back to off on local installs when the server flag is unset', () => {
vi.spyOn(api, 'getServerFeature').mockImplementation(
(_name, fallback) => fallback
)
expect(getDefault('Comfy.Notification.ShowVersionUpdates')).toBe(false)
})
it('falls back to on for Cloud when the server flag is unset', () => {
mockDist.isCloud = true
vi.spyOn(api, 'getServerFeature').mockImplementation(
(_name, fallback) => fallback
)
expect(getDefault('Comfy.Notification.ShowVersionUpdates')).toBe(true)
})
})

View File

@@ -6,6 +6,8 @@ import {
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import { ServerFeatureFlag } from '@/composables/useFeatureFlags'
import { api } from '@/scripts/api'
import type { ColorPalettes } from '@/schemas/colorPaletteSchema'
import type { Keybinding } from '@/platform/keybindings/types'
import { NodeBadgeMode } from '@/types/nodeSource'
@@ -484,7 +486,8 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Show version updates',
tooltip: 'Show updates for new models, and major new features.',
type: 'boolean',
defaultValue: true
defaultValue: () =>
api.getServerFeature(ServerFeatureFlag.SHOW_VERSION_UPDATES, isCloud)
},
{
id: 'Comfy.ConfirmClear',

View File

@@ -0,0 +1,118 @@
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { api } from '@/scripts/api'
import { useReleaseService } from '@/platform/updates/common/releaseService'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { createTestingPinia } from '@pinia/testing'
import type { SystemStats } from '@/types'
// Unlike releaseStore.test.ts, this file does NOT mock '@vueuse/core', so the
// real `until` runs and the server-feature-flag gate in initialize() is
// genuinely exercised (regression coverage for the default-off fix).
const mockData = vi.hoisted(() => ({ isCloud: false }))
vi.mock('@/platform/distribution/types', () => ({
isDesktop: false,
get isCloud() {
return mockData.isCloud
}
}))
vi.mock('@/platform/updates/common/releaseService', async () => {
const { ref } = await import('vue')
const getReleases = vi.fn()
const isLoading = ref(false)
const error = ref<string | null>(null)
return {
useReleaseService: () => ({ getReleases, isLoading, error })
}
})
vi.mock('@/scripts/api', async () => {
const { ref } = await import('vue')
return {
api: { serverFeatureFlags: ref<Record<string, unknown>>({}) }
}
})
vi.mock('@/platform/settings/settingStore', () => {
const get = vi.fn((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
return {
useSettingStore: () => ({ get, set: vi.fn(), setMany: vi.fn() })
}
})
vi.mock('@/stores/systemStatsStore', () => {
const systemStats: { system: Partial<SystemStats['system']> } = {
system: { comfyui_version: '1.0.0', argv: [] }
}
return {
useSystemStatsStore: () => ({
systemStats,
isInitialized: true,
getFormFactor: vi.fn(() => 'git-windows')
})
}
})
describe('useReleaseStore initialize gating', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
api.serverFeatureFlags.value = {}
mockData.isCloud = false
vi.mocked(useReleaseService().getReleases).mockResolvedValue([])
})
afterEach(() => {
vi.useRealTimers()
})
it('waits for server feature flags before fetching releases', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const initializePromise = store.initialize()
await nextTick()
await Promise.resolve()
expect(releaseService.getReleases).not.toHaveBeenCalled()
api.serverFeatureFlags.value = { show_version_updates: true }
await initializePromise
expect(releaseService.getReleases).toHaveBeenCalledTimes(1)
})
it('falls back to fetching after the timeout when flags never arrive', async () => {
vi.useFakeTimers()
const store = useReleaseStore()
const releaseService = useReleaseService()
const initializePromise = store.initialize()
await Promise.resolve()
expect(releaseService.getReleases).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(3000)
await initializePromise
expect(releaseService.getReleases).toHaveBeenCalledTimes(1)
})
it('does not gate on feature flags for cloud installs', async () => {
mockData.isCloud = true
const store = useReleaseStore()
const releaseService = useReleaseService()
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledTimes(1)
})
})

View File

@@ -18,13 +18,13 @@ vi.mock('semver', () => ({
valid: vi.fn(() => '1.0.0')
}))
const mockData = vi.hoisted(() => ({ isDesktop: true }))
const mockData = vi.hoisted(() => ({ isCloud: false }))
vi.mock('@/platform/distribution/types', () => ({
get isDesktop() {
return mockData.isDesktop
},
isCloud: false
isDesktop: false,
get isCloud() {
return mockData.isCloud
}
}))
vi.mock('@/platform/updates/common/releaseService', () => {
@@ -40,6 +40,14 @@ vi.mock('@/platform/updates/common/releaseService', () => {
}
})
vi.mock('@/scripts/api', () => ({
api: {
// Non-empty => feature flags already received, so fetchReleases' gate
// resolves immediately instead of waiting on the websocket.
serverFeatureFlags: ref<Record<string, unknown>>({ ready: true })
}
}))
vi.mock('@/platform/settings/settingStore', () => {
const get = vi.fn((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
@@ -95,7 +103,12 @@ vi.mock('@/stores/systemStatsStore', () => {
}
})
vi.mock('@vueuse/core', () => ({
until: vi.fn(() => Promise.resolve()),
// until() is awaited directly in some call sites and chained as
// until(...).toBe(...) in others; support both shapes.
until: vi.fn(() => {
const resolved = Promise.resolve()
return Object.assign(resolved, { toBe: vi.fn(() => Promise.resolve()) })
}),
useStorage: vi.fn(() => ({ value: {} })),
createSharedComposable: vi.fn((fn) => fn)
}))
@@ -115,6 +128,7 @@ describe('useReleaseStore', () => {
vi.resetAllMocks()
mockSystemStatsState.reset()
mockData.isCloud = false
})
describe('initial state', () => {
@@ -442,11 +456,11 @@ describe('useReleaseStore', () => {
vi.mocked(releaseService.getReleases).mockReturnValue(promise)
const initPromise = store.initialize()
const fetchPromise = store.fetchReleases()
expect(store.isLoading).toBe(true)
resolvePromise!([mockRelease])
await initPromise
await fetchPromise
expect(store.isLoading).toBe(false)
})
@@ -681,102 +695,40 @@ describe('useReleaseStore', () => {
})
})
describe('isDesktop environment checks', () => {
describe('when running on desktop', () => {
beforeEach(() => {
mockData.isDesktop = true
})
it('should show toast when conditions are met', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot when new version available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(true)
describe('cloud distribution', () => {
beforeEach(() => {
mockData.isCloud = true
const settingStore = useSettingStore()
vi.mocked(settingStore.get).mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
})
describe('when NOT running on desktop (web)', () => {
beforeEach(() => {
mockData.isDesktop = false
})
it('suppresses the toast even when a new version is available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
it('should NOT show toast even when all other conditions are met', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowToast).toBe(false)
})
// Set up all conditions that would normally show toast
store.releases = [mockRelease]
it('suppresses the red dot even when a new version is available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowToast).toBe(false)
})
expect(store.shouldShowRedDot).toBe(false)
})
it('should NOT show red dot even when new version available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
it("still shows the what's-new popup for the latest version", () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)
expect(store.shouldShowRedDot).toBe(false)
})
it('should NOT show toast regardless of attention level', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
// Test with high attention releases
const highRelease = {
...mockRelease,
id: 2,
attention: 'high' as const
}
const mediumRelease = {
...mockRelease,
id: 3,
attention: 'medium' as const
}
store.releases = [highRelease, mediumRelease]
expect(store.shouldShowToast).toBe(false)
})
it('should NOT show red dot even with high attention release', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
store.releases = [{ ...mockRelease, attention: 'high' as const }]
expect(store.shouldShowRedDot).toBe(false)
})
it('should NOT show popup even for latest version', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(false)
})
expect(store.shouldShowPopup).toBe(true)
})
})
})

View File

@@ -3,8 +3,9 @@ import { defineStore } from 'pinia'
import { compare, valid } from 'semver'
import { computed, ref } from 'vue'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { stringToLocale } from '@/utils/formatUtil'
@@ -93,8 +94,8 @@ export const useReleaseStore = defineStore('release', () => {
// Show toast if needed
const shouldShowToast = computed(() => {
// Only show on desktop version
if (!isDesktop || isCloud) {
// Cloud has its own update surface; the toast is for local installs.
if (isCloud) {
return false
}
@@ -125,8 +126,8 @@ export const useReleaseStore = defineStore('release', () => {
// Show red-dot indicator
const shouldShowRedDot = computed(() => {
// Only show on desktop version
if (!isDesktop || isCloud) {
// Cloud has its own update surface; the red dot is for local installs.
if (isCloud) {
return false
}
@@ -171,10 +172,8 @@ export const useReleaseStore = defineStore('release', () => {
})
const shouldShowPopup = computed(() => {
if (!isDesktop && !isCloud) {
return false
}
// Gated purely by the setting: off by default on local installs,
// on by default on Cloud (seeded by the server feature flag fallback).
if (!showVersionUpdates.value) {
return false
}
@@ -294,6 +293,16 @@ export const useReleaseStore = defineStore('release', () => {
// Initialize store
async function initialize(): Promise<void> {
// showVersionUpdates' default is seeded by the show_version_updates server
// feature flag, which arrives over the websocket shortly after connect. Wait
// for the flags (non-empty once received) so a host that disabled updates
// isn't briefly treated as enabled on first load; fall back after a timeout
// so a missing/late flag message can't block fetching forever.
if (!isCloud) {
await until(
() => Object.keys(api.serverFeatureFlags.value).length > 0
).toBe(true, { timeout: 3000 })
}
await fetchReleases()
}