[System Pop Up] Add help center with release notifications and "What's New" popup (#4256)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
bmcomfy
2025-06-26 14:11:15 -07:00
committed by GitHub
parent c2ae40bab5
commit 2d2cec2e79
23 changed files with 2952 additions and 1 deletions

View File

@@ -0,0 +1,220 @@
import axios from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useReleaseService } from '@/services/releaseService'
// Hoist the mock to avoid hoisting issues
const mockAxiosInstance = vi.hoisted(() => ({
get: vi.fn()
}))
vi.mock('axios', () => ({
default: {
create: vi.fn(() => mockAxiosInstance),
isAxiosError: vi.fn()
}
}))
describe('useReleaseService', () => {
let service: ReturnType<typeof useReleaseService>
const mockReleases = [
{
id: 1,
project: 'comfyui' as const,
version: '1.2.0',
attention: 'high' as const,
content: 'New features and improvements',
published_at: '2023-12-01T00:00:00Z'
}
]
beforeEach(() => {
vi.clearAllMocks()
service = useReleaseService()
})
it('should initialize with default state', () => {
expect(service.isLoading.value).toBe(false)
expect(service.error.value).toBeNull()
})
describe('getReleases', () => {
it('should fetch releases successfully', async () => {
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
const result = await service.getReleases({
project: 'comfyui',
current_version: '1.0.0'
})
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: {
project: 'comfyui',
current_version: '1.0.0'
},
signal: undefined
})
expect(result).toEqual(mockReleases)
expect(service.isLoading.value).toBe(false)
expect(service.error.value).toBeNull()
})
it('should fetch releases with form_factor parameter', async () => {
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
const result = await service.getReleases({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-windows'
})
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: {
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-windows'
},
signal: undefined
})
expect(result).toEqual(mockReleases)
})
it('should pass abort signal when provided', async () => {
const abortController = new AbortController()
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
await service.getReleases({ project: 'comfyui' }, abortController.signal)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: { project: 'comfyui' },
signal: abortController.signal
})
})
it('should handle API errors with response', async () => {
const errorResponse = {
response: {
status: 400,
data: { message: 'Invalid parameters' }
}
}
mockAxiosInstance.get.mockRejectedValue(errorResponse)
vi.mocked(axios.isAxiosError).mockReturnValue(true)
const result = await service.getReleases({ project: 'comfyui' })
expect(result).toBeNull()
expect(service.error.value).toBe('Invalid project or version parameter')
expect(service.isLoading.value).toBe(false)
})
it('should handle 401 errors', async () => {
const errorResponse = {
response: {
status: 401,
data: { message: 'Unauthorized' }
}
}
mockAxiosInstance.get.mockRejectedValue(errorResponse)
const result = await service.getReleases({ project: 'comfyui' })
expect(result).toBeNull()
expect(service.error.value).toBe('Unauthorized: Authentication required')
})
it('should handle 404 errors', async () => {
const errorResponse = {
response: {
status: 404,
data: { message: 'Not found' }
}
}
mockAxiosInstance.get.mockRejectedValue(errorResponse)
const result = await service.getReleases({ project: 'comfyui' })
expect(result).toBeNull()
expect(service.error.value).toBe('Not found: Not found')
})
it('should handle 500 errors', async () => {
const errorResponse = {
response: {
status: 500,
data: { message: 'Server error' }
}
}
mockAxiosInstance.get.mockRejectedValue(errorResponse)
const result = await service.getReleases({ project: 'comfyui' })
expect(result).toBeNull()
expect(service.error.value).toBe('Server error: Server error')
})
it('should handle network errors', async () => {
const networkError = new Error('Network Error')
mockAxiosInstance.get.mockRejectedValue(networkError)
const result = await service.getReleases({ project: 'comfyui' })
expect(result).toBeNull()
expect(service.error.value).toBe('Failed to get releases: Network Error')
})
it('should handle abort errors gracefully', async () => {
const abortError = {
name: 'AbortError',
message: 'Request aborted'
}
mockAxiosInstance.get.mockRejectedValue(abortError)
const result = await service.getReleases({ project: 'comfyui' })
expect(result).toBeNull()
expect(service.error.value).toContain('Request aborted') // Abort errors are handled
})
it('should handle non-Error objects', async () => {
const stringError = 'String error'
mockAxiosInstance.get.mockRejectedValue(stringError)
const result = await service.getReleases({ project: 'comfyui' })
expect(result).toBeNull()
expect(service.error.value).toBe('Failed to get releases: undefined')
})
it('should set loading state correctly', async () => {
let resolvePromise: (value: any) => void
const promise = new Promise((resolve) => {
resolvePromise = resolve
})
mockAxiosInstance.get.mockReturnValue(promise)
const fetchPromise = service.getReleases({ project: 'comfyui' })
expect(service.isLoading.value).toBe(true)
resolvePromise!({ data: mockReleases })
await fetchPromise
expect(service.isLoading.value).toBe(false)
})
it('should reset error state on new request', async () => {
// First request fails
mockAxiosInstance.get.mockRejectedValueOnce(new Error('First error'))
await service.getReleases({ project: 'comfyui' })
expect(service.error.value).toBe('Failed to get releases: First error')
// Second request succeeds
mockAxiosInstance.get.mockResolvedValueOnce({ data: mockReleases })
await service.getReleases({ project: 'comfyui' })
expect(service.error.value).toBeNull()
})
})
})

View File

@@ -0,0 +1,289 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useReleaseStore } from '@/stores/releaseStore'
// Mock the dependencies
vi.mock('@/utils/formatUtil')
vi.mock('@/services/releaseService')
vi.mock('@/stores/settingStore')
vi.mock('@/stores/systemStatsStore')
describe('useReleaseStore', () => {
let store: ReturnType<typeof useReleaseStore>
let mockReleaseService: any
let mockSettingStore: any
let mockSystemStatsStore: any
const mockRelease = {
id: 1,
project: 'comfyui' as const,
version: '1.2.0',
content: 'New features and improvements',
published_at: '2023-12-01T00:00:00Z',
attention: 'high' as const
}
beforeEach(async () => {
setActivePinia(createPinia())
// Reset all mocks
vi.clearAllMocks()
// Setup mock services
mockReleaseService = {
getReleases: vi.fn(),
isLoading: { value: false },
error: { value: null }
}
mockSettingStore = {
get: vi.fn(),
set: vi.fn()
}
mockSystemStatsStore = {
systemStats: {
system: {
comfyui_version: '1.0.0'
}
},
fetchSystemStats: vi.fn(),
getFormFactor: vi.fn(() => 'git-windows')
}
// Setup mock implementations
const { useReleaseService } = await import('@/services/releaseService')
const { useSettingStore } = await import('@/stores/settingStore')
const { useSystemStatsStore } = await import('@/stores/systemStatsStore')
vi.mocked(useReleaseService).mockReturnValue(mockReleaseService)
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore)
vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore)
store = useReleaseStore()
})
describe('initial state', () => {
it('should initialize with default state', () => {
expect(store.releases).toEqual([])
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
})
})
describe('computed properties', () => {
it('should return most recent release', () => {
const olderRelease = {
...mockRelease,
id: 2,
version: '1.1.0',
published_at: '2023-11-01T00:00:00Z'
}
store.releases = [mockRelease, olderRelease]
expect(store.recentRelease).toEqual(mockRelease)
})
it('should return 3 most recent releases', () => {
const releases = [
mockRelease,
{ ...mockRelease, id: 2, version: '1.1.0' },
{ ...mockRelease, id: 3, version: '1.0.0' },
{ ...mockRelease, id: 4, version: '0.9.0' }
]
store.releases = releases
expect(store.recentReleases).toEqual(releases.slice(0, 3))
})
it('should show update button (shouldShowUpdateButton)', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1) // newer version available
store.releases = [mockRelease]
expect(store.shouldShowUpdateButton).toBe(true)
})
it('should not show update button when no new version', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(-1) // current version is newer
store.releases = [mockRelease]
expect(store.shouldShowUpdateButton).toBe(false)
})
})
describe('release initialization', () => {
it('should fetch releases successfully', async () => {
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows'
})
expect(store.releases).toEqual([mockRelease])
})
it('should include form_factor in API call', async () => {
mockSystemStatsStore.getFormFactor.mockReturnValue('desktop-mac')
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac'
})
})
it('should handle API errors gracefully', async () => {
mockReleaseService.getReleases.mockResolvedValue(null)
mockReleaseService.error.value = 'API Error'
await store.initialize()
expect(store.releases).toEqual([])
expect(store.error).toBe('API Error')
})
it('should handle non-Error objects', async () => {
mockReleaseService.getReleases.mockRejectedValue('String error')
await store.initialize()
expect(store.error).toBe('Unknown error occurred')
})
it('should set loading state correctly', async () => {
let resolvePromise: (value: any) => void
const promise = new Promise((resolve) => {
resolvePromise = resolve
})
mockReleaseService.getReleases.mockReturnValue(promise)
const initPromise = store.initialize()
expect(store.isLoading).toBe(true)
resolvePromise!([mockRelease])
await initPromise
expect(store.isLoading).toBe(false)
})
it('should fetch system stats if not available', async () => {
mockSystemStatsStore.systemStats = null
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
})
})
describe('action handlers', () => {
beforeEach(() => {
store.releases = [mockRelease]
})
it('should handle skip release', async () => {
await store.handleSkipRelease('1.2.0')
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
'skipped'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
})
it('should handle show changelog', async () => {
await store.handleShowChangelog('1.2.0')
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
'changelog seen'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
})
it('should handle whats new seen', async () => {
await store.handleWhatsNewSeen('1.2.0')
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
"what's new seen"
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
})
})
describe('popup visibility', () => {
it('should show toast for medium/high attention releases', async () => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Release.Version') return null
if (key === 'Comfy.Release.Status') return null
return null
})
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
const mediumRelease = { ...mockRelease, attention: 'medium' as const }
store.releases = [
mockRelease,
mediumRelease,
{ ...mockRelease, attention: 'low' as const }
]
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot for new versions', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
mockSettingStore.get.mockReturnValue(null)
store.releases = [mockRelease]
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', async () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0' // Same as release
mockSettingStore.get.mockReturnValue(null)
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0) // versions are equal (latest version)
store.releases = [mockRelease]
expect(store.shouldShowPopup).toBe(true)
})
})
})

View File

@@ -0,0 +1,322 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { isElectron } from '@/utils/envUtil'
// Mock the API
vi.mock('@/scripts/api', () => ({
api: {
getSystemStats: vi.fn()
}
}))
// Mock the envUtil
vi.mock('@/utils/envUtil', () => ({
isElectron: vi.fn()
}))
describe('useSystemStatsStore', () => {
let store: ReturnType<typeof useSystemStatsStore>
beforeEach(() => {
setActivePinia(createPinia())
store = useSystemStatsStore()
vi.clearAllMocks()
})
it('should initialize with null systemStats', () => {
expect(store.systemStats).toBeNull()
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
})
describe('fetchSystemStats', () => {
it('should fetch system stats successfully', async () => {
const mockStats = {
system: {
os: 'Windows',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
vi.mocked(api.getSystemStats).mockResolvedValue(mockStats)
await store.fetchSystemStats()
expect(store.systemStats).toEqual(mockStats)
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
expect(api.getSystemStats).toHaveBeenCalled()
})
it('should handle API errors', async () => {
const error = new Error('API Error')
vi.mocked(api.getSystemStats).mockRejectedValue(error)
await store.fetchSystemStats()
expect(store.systemStats).toBeNull()
expect(store.isLoading).toBe(false)
expect(store.error).toBe('API Error')
})
it('should handle non-Error objects', async () => {
vi.mocked(api.getSystemStats).mockRejectedValue('String error')
await store.fetchSystemStats()
expect(store.error).toBe('An error occurred while fetching system stats')
})
it('should set loading state correctly', async () => {
let resolvePromise: (value: any) => void = () => {}
const promise = new Promise<any>((resolve) => {
resolvePromise = resolve
})
vi.mocked(api.getSystemStats).mockReturnValue(promise)
const fetchPromise = store.fetchSystemStats()
expect(store.isLoading).toBe(true)
resolvePromise({})
await fetchPromise
expect(store.isLoading).toBe(false)
})
})
describe('getFormFactor', () => {
beforeEach(() => {
// Reset systemStats for each test
store.systemStats = null
})
it('should return "other" when systemStats is null', () => {
expect(store.getFormFactor()).toBe('other')
})
it('should return "other" when os is not available', () => {
store.systemStats = {
system: {
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
} as any,
devices: []
}
expect(store.getFormFactor()).toBe('other')
})
describe('desktop environment (Electron)', () => {
beforeEach(() => {
vi.mocked(isElectron).mockReturnValue(true)
})
it('should return "desktop-windows" for Windows desktop', () => {
store.systemStats = {
system: {
os: 'Windows 11',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('desktop-windows')
})
it('should return "desktop-mac" for macOS desktop', () => {
store.systemStats = {
system: {
os: 'Darwin 22.0.0',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('desktop-mac')
})
it('should return "desktop-mac" for Mac desktop', () => {
store.systemStats = {
system: {
os: 'Mac OS X 13.0',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('desktop-mac')
})
it('should return "other" for unknown desktop OS', () => {
store.systemStats = {
system: {
os: 'Linux',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('other')
})
})
describe('git environment (non-Electron)', () => {
beforeEach(() => {
vi.mocked(isElectron).mockReturnValue(false)
})
it('should return "git-windows" for Windows git', () => {
store.systemStats = {
system: {
os: 'Windows 11',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('git-windows')
})
it('should return "git-mac" for macOS git', () => {
store.systemStats = {
system: {
os: 'Darwin 22.0.0',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('git-mac')
})
it('should return "git-linux" for Linux git', () => {
store.systemStats = {
system: {
os: 'linux Ubuntu 22.04',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('git-linux')
})
it('should return "other" for unknown git OS', () => {
store.systemStats = {
system: {
os: 'FreeBSD',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('other')
})
})
describe('case insensitive OS detection', () => {
beforeEach(() => {
vi.mocked(isElectron).mockReturnValue(false)
})
it('should handle uppercase OS names', () => {
store.systemStats = {
system: {
os: 'WINDOWS',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('git-windows')
})
it('should handle mixed case OS names', () => {
store.systemStats = {
system: {
os: 'LiNuX',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('git-linux')
})
})
})
})