mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 21:58:32 +00:00
Compare commits
4 Commits
matt/be-22
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7d5b9a0f5 | ||
|
|
1ba3f7c91b | ||
|
|
1ca2c86df1 | ||
|
|
4faf1de7be |
@@ -0,0 +1,164 @@
|
||||
import { createApp, h, nextTick, reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const {
|
||||
managerStore,
|
||||
nodePacksState,
|
||||
nodePacksError,
|
||||
nodePacksLoading,
|
||||
nodePacksReady,
|
||||
startFetch,
|
||||
cleanup,
|
||||
useNodePacks
|
||||
} = vi.hoisted(() => ({
|
||||
managerStore: {
|
||||
installedPacksIds: new Set<string>(),
|
||||
installedPacks: {},
|
||||
refreshInstalledList: vi.fn(),
|
||||
isPackInstalled: vi.fn()
|
||||
},
|
||||
nodePacksState: { value: [] as NodePack[] },
|
||||
nodePacksError: { value: undefined as unknown },
|
||||
nodePacksLoading: { value: false },
|
||||
nodePacksReady: { value: false },
|
||||
startFetch: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
useNodePacks: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => managerStore
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/nodePack/useNodePacks',
|
||||
() => ({
|
||||
useNodePacks
|
||||
})
|
||||
)
|
||||
|
||||
function mountInstalledPacks() {
|
||||
let result: ReturnType<typeof useInstalledPacks> | undefined
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = useInstalledPacks()
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('useInstalledPacks did not initialize')
|
||||
return {
|
||||
result,
|
||||
unmount: () => app.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
function pack(overrides: Partial<NodePack> = {}): NodePack {
|
||||
return { id: 'pack-a', ...overrides } as NodePack
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
managerStore.installedPacksIds = reactive(new Set<string>())
|
||||
managerStore.installedPacks = reactive({})
|
||||
managerStore.refreshInstalledList.mockReset().mockResolvedValue(undefined)
|
||||
managerStore.isPackInstalled.mockReset().mockReturnValue(false)
|
||||
startFetch.mockReset().mockResolvedValue([])
|
||||
cleanup.mockReset()
|
||||
useNodePacks.mockReset().mockReturnValue({
|
||||
error: nodePacksError,
|
||||
isLoading: nodePacksLoading,
|
||||
isReady: nodePacksReady,
|
||||
nodePacks: nodePacksState,
|
||||
startFetch,
|
||||
cleanup
|
||||
})
|
||||
})
|
||||
|
||||
describe('useInstalledPacks', () => {
|
||||
it('refreshes an empty installed list before fetching packs', async () => {
|
||||
const { result, unmount } = mountInstalledPacks()
|
||||
|
||||
await result.startFetchInstalled()
|
||||
|
||||
expect(managerStore.refreshInstalledList).toHaveBeenCalledTimes(1)
|
||||
expect(startFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not refresh when installed pack ids are already present', async () => {
|
||||
managerStore.installedPacksIds.add('pack-a')
|
||||
const { result, unmount } = mountInstalledPacks()
|
||||
|
||||
await result.startFetchInstalled()
|
||||
|
||||
expect(managerStore.refreshInstalledList).not.toHaveBeenCalled()
|
||||
expect(startFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('prevents duplicate initialization fetches', async () => {
|
||||
let releaseRefresh: (() => void) | undefined
|
||||
managerStore.refreshInstalledList.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
releaseRefresh = resolve
|
||||
})
|
||||
)
|
||||
const { result, unmount } = mountInstalledPacks()
|
||||
|
||||
const firstFetch = result.startFetchInstalled()
|
||||
await result.startFetchInstalled()
|
||||
releaseRefresh?.()
|
||||
await firstFetch
|
||||
|
||||
expect(managerStore.refreshInstalledList).toHaveBeenCalledTimes(1)
|
||||
expect(startFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('fetches again when installed ids change', async () => {
|
||||
const { unmount } = mountInstalledPacks()
|
||||
|
||||
managerStore.installedPacksIds.add('pack-b')
|
||||
await nextTick()
|
||||
|
||||
expect(startFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('filters and exposes installed pack versions', () => {
|
||||
managerStore.isPackInstalled.mockImplementation((id?: string) => id === 'x')
|
||||
Object.assign(managerStore.installedPacks, {
|
||||
a: { cnr_id: 'x', ver: '1.0.0' },
|
||||
b: { aux_id: 'y' },
|
||||
c: { ver: 'missing-id' }
|
||||
})
|
||||
const { result, unmount } = mountInstalledPacks()
|
||||
|
||||
expect(
|
||||
result.filterInstalledPack([pack({ id: 'x' }), pack({ id: 'z' })])
|
||||
).toEqual([pack({ id: 'x' })])
|
||||
expect(result.installedPacksWithVersions.value).toEqual([
|
||||
{ id: 'x', version: '1.0.0' },
|
||||
{ id: 'y', version: '' }
|
||||
])
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('cleans up node pack fetching on unmount', () => {
|
||||
const { unmount } = mountInstalledPacks()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(cleanup).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,269 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
type CompatibilityCheck = {
|
||||
hasConflict: boolean
|
||||
conflicts: ConflictDetail[]
|
||||
}
|
||||
|
||||
const { managerStore, showDialog, checkNodeCompatibility } = vi.hoisted(() => ({
|
||||
managerStore: {
|
||||
installPack: { call: vi.fn(), clear: vi.fn() },
|
||||
isPackInstalling: vi.fn((_id?: string) => false),
|
||||
isPackInstalled: vi.fn((_id?: string) => false)
|
||||
},
|
||||
showDialog: vi.fn(),
|
||||
checkNodeCompatibility: vi.fn(
|
||||
(): CompatibilityCheck => ({ hasConflict: false, conflicts: [] })
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => managerStore
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useNodeConflictDialog',
|
||||
() => ({
|
||||
useNodeConflictDialog: () => ({ show: showDialog })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictDetection',
|
||||
() => ({
|
||||
useConflictDetection: () => ({ checkNodeCompatibility })
|
||||
})
|
||||
)
|
||||
|
||||
function pack(over: Partial<NodePack> = {}): NodePack {
|
||||
return fromPartial<NodePack>({ id: 'pack-a', name: 'Pack A', ...over })
|
||||
}
|
||||
|
||||
function conflict(overrides: Partial<ConflictDetail> = {}): ConflictDetail {
|
||||
return {
|
||||
type: 'os',
|
||||
current_value: 'linux',
|
||||
required_value: 'darwin',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
managerStore.installPack.call.mockReset().mockResolvedValue(undefined)
|
||||
managerStore.installPack.clear.mockReset()
|
||||
managerStore.isPackInstalling.mockReset().mockReturnValue(false)
|
||||
managerStore.isPackInstalled.mockReset().mockReturnValue(false)
|
||||
showDialog.mockReset()
|
||||
checkNodeCompatibility.mockReset().mockReturnValue({
|
||||
hasConflict: false,
|
||||
conflicts: []
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePackInstall', () => {
|
||||
it('reports isInstalling when any pack is installing', () => {
|
||||
managerStore.isPackInstalling.mockImplementation(
|
||||
(id?: string) => id === 'pack-b'
|
||||
)
|
||||
const { isInstalling } = usePackInstall(() => [
|
||||
pack(),
|
||||
pack({ id: 'pack-b' })
|
||||
])
|
||||
expect(isInstalling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('reports not installing for an empty or idle pack list', () => {
|
||||
expect(usePackInstall(() => []).isInstalling.value).toBe(false)
|
||||
expect(usePackInstall(() => [pack()]).isInstalling.value).toBe(false)
|
||||
})
|
||||
|
||||
it('installs each pack and clears the command afterward', async () => {
|
||||
const { performInstallation } = usePackInstall(() => [])
|
||||
await performInstallation([
|
||||
pack({
|
||||
id: 'a',
|
||||
latest_version: { version: '1.2.0' }
|
||||
} as Partial<NodePack>),
|
||||
pack({ id: 'b', publisher: { name: 'Unclaimed' } } as Partial<NodePack>)
|
||||
])
|
||||
|
||||
expect(managerStore.installPack.call).toHaveBeenCalledTimes(2)
|
||||
expect(managerStore.installPack.call).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'a', selected_version: '1.2.0' })
|
||||
)
|
||||
expect(managerStore.installPack.call).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'b', selected_version: 'nightly' })
|
||||
)
|
||||
expect(managerStore.installPack.clear).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('installAllPacks installs only the not-yet-installed packs', async () => {
|
||||
managerStore.isPackInstalled.mockImplementation(
|
||||
(id?: string) => id === 'installed'
|
||||
)
|
||||
const { installAllPacks } = usePackInstall(() => [
|
||||
pack({ id: 'installed' }),
|
||||
pack({ id: 'fresh' })
|
||||
])
|
||||
|
||||
await installAllPacks()
|
||||
|
||||
expect(managerStore.installPack.call).toHaveBeenCalledTimes(1)
|
||||
expect(managerStore.installPack.call).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'fresh' })
|
||||
)
|
||||
})
|
||||
|
||||
it('installAllPacks returns early for empty or already installed packs', async () => {
|
||||
await usePackInstall(() => []).installAllPacks()
|
||||
|
||||
managerStore.isPackInstalled.mockReturnValue(true)
|
||||
await usePackInstall(() => [pack({ id: 'installed' })]).installAllPacks()
|
||||
|
||||
expect(managerStore.installPack.call).not.toHaveBeenCalled()
|
||||
expect(managerStore.installPack.clear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('installAllPacks opens the conflict dialog instead of installing when conflicted', async () => {
|
||||
const osConflict = conflict()
|
||||
checkNodeCompatibility.mockReturnValue({
|
||||
hasConflict: true,
|
||||
conflicts: [osConflict]
|
||||
})
|
||||
const { installAllPacks } = usePackInstall(
|
||||
() => [pack({ id: 'x' })],
|
||||
() => true,
|
||||
() => [osConflict]
|
||||
)
|
||||
|
||||
await installAllPacks()
|
||||
|
||||
expect(showDialog).toHaveBeenCalledTimes(1)
|
||||
expect(showDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conflictedPackages: [
|
||||
expect.objectContaining({
|
||||
package_id: 'x',
|
||||
package_name: 'Pack A',
|
||||
has_conflict: true,
|
||||
conflicts: [osConflict],
|
||||
is_compatible: false
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
expect(managerStore.installPack.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('installAllPacks stops when conflict details are unavailable', async () => {
|
||||
const { installAllPacks } = usePackInstall(
|
||||
() => [pack({ id: 'x' })],
|
||||
() => true
|
||||
)
|
||||
|
||||
await installAllPacks()
|
||||
|
||||
expect(showDialog).not.toHaveBeenCalled()
|
||||
expect(managerStore.installPack.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('conflict dialog payload falls back for unnamed package data', async () => {
|
||||
checkNodeCompatibility.mockReturnValue({
|
||||
hasConflict: true,
|
||||
conflicts: [conflict()]
|
||||
})
|
||||
const { installAllPacks } = usePackInstall(
|
||||
() => [pack({ id: undefined, name: undefined })],
|
||||
() => true,
|
||||
() => [conflict()]
|
||||
)
|
||||
|
||||
await installAllPacks()
|
||||
|
||||
expect(showDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conflictedPackages: [
|
||||
expect.objectContaining({
|
||||
package_id: '',
|
||||
package_name: ''
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('conflict dialog action installs only packs still missing', async () => {
|
||||
checkNodeCompatibility.mockReturnValue({
|
||||
hasConflict: false,
|
||||
conflicts: []
|
||||
})
|
||||
managerStore.isPackInstalled.mockImplementation(
|
||||
(id?: string) => id === 'installed'
|
||||
)
|
||||
const { installAllPacks } = usePackInstall(
|
||||
() => [pack({ id: 'installed' }), pack({ id: 'fresh' })],
|
||||
() => true,
|
||||
() => [conflict()]
|
||||
)
|
||||
|
||||
await installAllPacks()
|
||||
const [{ onButtonClick }] = showDialog.mock.calls[0]
|
||||
await onButtonClick()
|
||||
|
||||
expect(managerStore.installPack.call).toHaveBeenCalledTimes(1)
|
||||
expect(managerStore.installPack.call).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'fresh' })
|
||||
)
|
||||
expect(managerStore.installPack.clear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('conflict dialog action returns when every pack is already installed', async () => {
|
||||
managerStore.isPackInstalled.mockReturnValue(true)
|
||||
const { installAllPacks } = usePackInstall(
|
||||
() => [pack({ id: 'installed' })],
|
||||
() => true,
|
||||
() => [conflict()]
|
||||
)
|
||||
|
||||
await installAllPacks()
|
||||
const [{ onButtonClick }] = showDialog.mock.calls[0]
|
||||
await onButtonClick()
|
||||
|
||||
expect(managerStore.installPack.call).not.toHaveBeenCalled()
|
||||
expect(managerStore.installPack.clear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the command when payload validation rejects', async () => {
|
||||
const { performInstallation } = usePackInstall(() => [])
|
||||
|
||||
await expect(
|
||||
performInstallation([pack({ id: undefined })])
|
||||
).rejects.toThrow('Node ID is required for installation')
|
||||
|
||||
expect(managerStore.installPack.call).not.toHaveBeenCalled()
|
||||
expect(managerStore.installPack.clear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('leaves command cleanup in finally when one install fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
managerStore.installPack.call
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('failed'))
|
||||
const { performInstallation } = usePackInstall(() => [])
|
||||
|
||||
await performInstallation([pack({ id: 'a' }), pack({ id: 'b' })])
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[usePackInstall] Some installations failed:',
|
||||
[expect.any(Error)]
|
||||
)
|
||||
expect(managerStore.installPack.clear).toHaveBeenCalledTimes(1)
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
@@ -18,7 +18,6 @@ export function usePackInstall(
|
||||
const managerStore = useComfyManagerStore()
|
||||
const { show: showNodeConflictDialog } = useNodeConflictDialog()
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Check if any of the packs are currently being installed
|
||||
const isInstalling = computed(() => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { managerStore } = vi.hoisted(() => ({
|
||||
managerStore: {
|
||||
isPackInstalled: vi.fn(),
|
||||
isPackEnabled: vi.fn(),
|
||||
getInstalledPackVersion: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => managerStore
|
||||
}))
|
||||
|
||||
function pack(overrides: Partial<NodePack> = {}): NodePack {
|
||||
return {
|
||||
id: 'pack-a',
|
||||
latest_version: { version: '1.2.0' },
|
||||
...overrides
|
||||
} as NodePack
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
managerStore.isPackInstalled.mockReset().mockReturnValue(true)
|
||||
managerStore.isPackEnabled.mockReset().mockReturnValue(true)
|
||||
managerStore.getInstalledPackVersion.mockReset().mockReturnValue('1.0.0')
|
||||
})
|
||||
|
||||
describe('usePackUpdateStatus', () => {
|
||||
it('detects semver updates for installed packs', () => {
|
||||
const status = usePackUpdateStatus(pack())
|
||||
|
||||
expect(status.installedVersion.value).toBe('1.0.0')
|
||||
expect(status.latestVersion.value).toBe('1.2.0')
|
||||
expect(status.isNightlyPack.value).toBe(false)
|
||||
expect(status.isUpdateAvailable.value).toBe(true)
|
||||
expect(status.canTryNightlyUpdate.value).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks update prompts when required version data is absent', () => {
|
||||
managerStore.isPackInstalled.mockReturnValue(false)
|
||||
expect(usePackUpdateStatus(pack()).isUpdateAvailable.value).toBe(false)
|
||||
|
||||
managerStore.isPackInstalled.mockReturnValue(true)
|
||||
managerStore.getInstalledPackVersion.mockReturnValue('')
|
||||
expect(usePackUpdateStatus(pack()).isUpdateAvailable.value).toBe(false)
|
||||
|
||||
managerStore.getInstalledPackVersion.mockReturnValue('1.0.0')
|
||||
expect(
|
||||
usePackUpdateStatus(pack({ latest_version: undefined })).isUpdateAvailable
|
||||
.value
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('allows enabled nightly packs to try update without semver comparison', () => {
|
||||
managerStore.getInstalledPackVersion.mockReturnValue('nightly')
|
||||
|
||||
const status = usePackUpdateStatus(pack())
|
||||
|
||||
expect(status.isNightlyPack.value).toBe(true)
|
||||
expect(status.isUpdateAvailable.value).toBe(false)
|
||||
expect(status.canTryNightlyUpdate.value).toBe(true)
|
||||
})
|
||||
|
||||
it('tracks reactive pack sources', () => {
|
||||
const nodePack = ref(pack({ latest_version: { version: '1.0.0' } }))
|
||||
const status = usePackUpdateStatus(nodePack)
|
||||
|
||||
expect(status.isUpdateAvailable.value).toBe(false)
|
||||
|
||||
nodePack.value = pack({ latest_version: { version: '2.0.0' } })
|
||||
|
||||
expect(status.latestVersion.value).toBe('2.0.0')
|
||||
expect(status.isUpdateAvailable.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -7,21 +7,13 @@ import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { usePacksSelection } from '@/workbench/extensions/manager/composables/nodePack/usePacksSelection'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: vi.fn((key) => key)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
describe('usePacksSelection', () => {
|
||||
let managerStore: ReturnType<typeof useComfyManagerStore>
|
||||
let mockIsPackInstalled: (packName: string | undefined) => boolean
|
||||
let mockGetInstalledPackVersion: (packName: string) => string | undefined
|
||||
let mockIsPackEnabled: (packName: string | undefined) => boolean
|
||||
|
||||
const createMockPack = (id: string): NodePack => ({
|
||||
id,
|
||||
@@ -42,9 +34,12 @@ describe('usePacksSelection', () => {
|
||||
|
||||
managerStore = useComfyManagerStore()
|
||||
|
||||
// Mock the isPackInstalled method
|
||||
mockIsPackInstalled = vi.fn()
|
||||
mockGetInstalledPackVersion = vi.fn()
|
||||
mockIsPackEnabled = vi.fn()
|
||||
managerStore.isPackInstalled = mockIsPackInstalled
|
||||
managerStore.getInstalledPackVersion = mockGetInstalledPackVersion
|
||||
managerStore.isPackEnabled = mockIsPackEnabled
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -375,5 +370,35 @@ describe('usePacksSelection', () => {
|
||||
expect(installedPacks.value).toHaveLength(2)
|
||||
expect(notInstalledPacks.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should only include enabled installed packs with non-semver versions as nightly', () => {
|
||||
const nodePacks = ref<NodePack[]>([
|
||||
{ ...createMockPack('missing-id'), id: undefined },
|
||||
createMockPack('no-version'),
|
||||
createMockPack('stable'),
|
||||
createMockPack('disabled-nightly'),
|
||||
createMockPack('enabled-nightly')
|
||||
])
|
||||
|
||||
vi.mocked(mockIsPackInstalled).mockReturnValue(true)
|
||||
vi.mocked(mockGetInstalledPackVersion).mockImplementation((id) => {
|
||||
const versions: Record<string, string | undefined> = {
|
||||
stable: '1.2.3',
|
||||
'disabled-nightly': 'abc123',
|
||||
'enabled-nightly': 'def456'
|
||||
}
|
||||
return versions[id]
|
||||
})
|
||||
vi.mocked(mockIsPackEnabled).mockImplementation(
|
||||
(id) => id === 'enabled-nightly'
|
||||
)
|
||||
|
||||
const { nightlyPacks, hasNightlyPacks } = usePacksSelection(nodePacks)
|
||||
|
||||
expect(nightlyPacks.value.map((pack) => pack.id)).toEqual([
|
||||
'enabled-nightly'
|
||||
])
|
||||
expect(hasNightlyPacks.value).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { createApp, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks'
|
||||
|
||||
import type * as VueUse from '@vueuse/core'
|
||||
|
||||
type GraphNode = {
|
||||
type?: string
|
||||
properties?: {
|
||||
cnr_id?: unknown
|
||||
aux_id?: unknown
|
||||
ver?: unknown
|
||||
}
|
||||
}
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const {
|
||||
appState,
|
||||
nodeDefStore,
|
||||
registryStore,
|
||||
systemStatsStore,
|
||||
nodePacksState,
|
||||
nodePacksError,
|
||||
nodePacksLoading,
|
||||
nodePacksReady,
|
||||
startFetch,
|
||||
cleanup,
|
||||
useNodePacks
|
||||
} = vi.hoisted(() => ({
|
||||
appState: {
|
||||
rootGraph: undefined as undefined | { nodes: GraphNode[] }
|
||||
},
|
||||
nodeDefStore: {
|
||||
nodeDefsByName: {} as Record<string, { isCoreNode?: boolean }>
|
||||
},
|
||||
registryStore: {
|
||||
inferPackFromNodeName: { call: vi.fn() }
|
||||
},
|
||||
systemStatsStore: {
|
||||
systemStats: undefined as
|
||||
| undefined
|
||||
| { system?: { comfyui_version?: string } },
|
||||
refetchSystemStats: vi.fn()
|
||||
},
|
||||
nodePacksState: { value: [] as NodePack[] },
|
||||
nodePacksError: { value: undefined as unknown },
|
||||
nodePacksLoading: { value: false },
|
||||
nodePacksReady: { value: false },
|
||||
startFetch: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
useNodePacks: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof VueUse>()
|
||||
return {
|
||||
...actual,
|
||||
createSharedComposable: <T extends (...args: never[]) => unknown>(fn: T) =>
|
||||
fn
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appState
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/comfyRegistryStore', () => ({
|
||||
useComfyRegistryStore: () => registryStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => nodeDefStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/systemStatsStore', () => ({
|
||||
useSystemStatsStore: () => systemStatsStore
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
mapAllNodes: (
|
||||
graph: { nodes: GraphNode[] },
|
||||
mapper: (node: GraphNode) => unknown
|
||||
) => graph.nodes.map(mapper)
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/nodePack/useNodePacks',
|
||||
() => ({
|
||||
useNodePacks
|
||||
})
|
||||
)
|
||||
|
||||
function mountWorkflowPacks() {
|
||||
let result: ReturnType<typeof useWorkflowPacks> | undefined
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = useWorkflowPacks()
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('useWorkflowPacks did not initialize')
|
||||
return {
|
||||
result,
|
||||
unmount: () => app.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
function node(overrides: GraphNode = {}): GraphNode {
|
||||
return {
|
||||
type: 'CustomNode',
|
||||
properties: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function pack(overrides: Partial<NodePack> = {}): NodePack {
|
||||
return { id: 'pack-a', ...overrides } as NodePack
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
appState.rootGraph = { nodes: [] }
|
||||
nodeDefStore.nodeDefsByName = {}
|
||||
registryStore.inferPackFromNodeName.call
|
||||
.mockReset()
|
||||
.mockResolvedValue(undefined)
|
||||
systemStatsStore.systemStats = undefined
|
||||
systemStatsStore.refetchSystemStats.mockReset().mockResolvedValue(undefined)
|
||||
startFetch.mockReset().mockResolvedValue([])
|
||||
cleanup.mockReset()
|
||||
useNodePacks.mockReset().mockReturnValue({
|
||||
error: nodePacksError,
|
||||
isLoading: nodePacksLoading,
|
||||
isReady: nodePacksReady,
|
||||
nodePacks: nodePacksState,
|
||||
startFetch,
|
||||
cleanup
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowPacks', () => {
|
||||
it('fetches explicit workflow packs and trims versions', async () => {
|
||||
appState.rootGraph = {
|
||||
nodes: [
|
||||
node({ properties: { cnr_id: 'pack-a', ver: ' v1.2.3\n' } }),
|
||||
node({ properties: { aux_id: 'pack-b' } }),
|
||||
node({ properties: { cnr_id: 'comfy-core' } })
|
||||
]
|
||||
}
|
||||
const { result, unmount } = mountWorkflowPacks()
|
||||
|
||||
await result.startFetchWorkflowPacks()
|
||||
|
||||
expect(useNodePacks).toHaveBeenCalled()
|
||||
const idsSource = useNodePacks.mock.calls[0][0]
|
||||
expect(idsSource.value).toEqual(['pack-a', 'pack-b'])
|
||||
expect(startFetch).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
result.filterWorkflowPack([
|
||||
pack({ id: 'pack-a' }),
|
||||
pack({ id: 'comfy-core' })
|
||||
])
|
||||
).toEqual([pack({ id: 'pack-a' })])
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('infers core node packs from system stats', async () => {
|
||||
nodeDefStore.nodeDefsByName = { KSampler: { isCoreNode: true } }
|
||||
systemStatsStore.systemStats = { system: { comfyui_version: '0.4.0' } }
|
||||
appState.rootGraph = { nodes: [node({ type: 'KSampler' })] }
|
||||
const { result, unmount } = mountWorkflowPacks()
|
||||
|
||||
await result.startFetchWorkflowPacks()
|
||||
|
||||
const idsSource = useNodePacks.mock.calls[0][0]
|
||||
expect(idsSource.value).toEqual(['comfy-core'])
|
||||
expect(systemStatsStore.refetchSystemStats).not.toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('refetches system stats and falls back to nightly for core nodes', async () => {
|
||||
nodeDefStore.nodeDefsByName = { KSampler: { isCoreNode: true } }
|
||||
appState.rootGraph = { nodes: [node({ type: 'KSampler' })] }
|
||||
const { result, unmount } = mountWorkflowPacks()
|
||||
|
||||
await result.startFetchWorkflowPacks()
|
||||
|
||||
const idsSource = useNodePacks.mock.calls[0][0]
|
||||
expect(idsSource.value).toEqual(['comfy-core'])
|
||||
expect(systemStatsStore.refetchSystemStats).toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('infers registry packs and tracks unresolved nodes', async () => {
|
||||
registryStore.inferPackFromNodeName.call.mockImplementation(
|
||||
(name: string) =>
|
||||
name === 'KnownNode'
|
||||
? Promise.resolve({
|
||||
id: 'registry-pack',
|
||||
latest_version: { version: '3.0.0' }
|
||||
})
|
||||
: Promise.resolve(undefined)
|
||||
)
|
||||
appState.rootGraph = {
|
||||
nodes: [node({ type: 'KnownNode' }), node({ type: 'MissingNode' })]
|
||||
}
|
||||
const { result, unmount } = mountWorkflowPacks()
|
||||
|
||||
await result.startFetchWorkflowPacks()
|
||||
|
||||
const idsSource = useNodePacks.mock.calls[0][0]
|
||||
expect(idsSource.value).toEqual(['registry-pack'])
|
||||
expect(result.unresolvedNodeNames.value).toEqual(['MissingNode'])
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('resets workflow packs when no graph is ready and cleans up on unmount', async () => {
|
||||
appState.rootGraph = undefined
|
||||
const { result, unmount } = mountWorkflowPacks()
|
||||
|
||||
await result.startFetchWorkflowPacks()
|
||||
unmount()
|
||||
|
||||
const idsSource = useNodePacks.mock.calls[0][0]
|
||||
expect(idsSource.value).toEqual([])
|
||||
expect(cleanup).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useApplyChanges } from '@/workbench/extensions/manager/composables/useApplyChanges'
|
||||
|
||||
import type * as VueUse from '@vueuse/core'
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
const {
|
||||
listeners,
|
||||
managerStore,
|
||||
settingStore,
|
||||
commandStore,
|
||||
workflowService,
|
||||
managerService,
|
||||
runFullConflictAnalysis,
|
||||
useEventListener
|
||||
} = vi.hoisted(() => ({
|
||||
listeners: new Map<string, Listener>(),
|
||||
managerStore: {
|
||||
setStale: vi.fn()
|
||||
},
|
||||
settingStore: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn()
|
||||
},
|
||||
commandStore: {
|
||||
execute: vi.fn()
|
||||
},
|
||||
workflowService: {
|
||||
reloadCurrentWorkflow: vi.fn()
|
||||
},
|
||||
managerService: {
|
||||
rebootComfyUI: vi.fn()
|
||||
},
|
||||
runFullConflictAnalysis: vi.fn(),
|
||||
useEventListener: vi.fn(
|
||||
(_target: unknown, event: string, listener: Listener) => {
|
||||
listeners.set(event, listener)
|
||||
return vi.fn(() => listeners.delete(event))
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof VueUse>()
|
||||
return {
|
||||
...actual,
|
||||
createSharedComposable: <T extends (...args: never[]) => unknown>(fn: T) =>
|
||||
fn,
|
||||
useEventListener
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => settingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => workflowService
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => commandStore
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictDetection',
|
||||
() => ({
|
||||
useConflictDetection: () => ({ runFullConflictAnalysis })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
|
||||
useComfyManagerService: () => managerService
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => managerStore
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
listeners.clear()
|
||||
managerStore.setStale.mockReset()
|
||||
settingStore.get.mockReset().mockReturnValue(false)
|
||||
settingStore.set.mockReset().mockResolvedValue(undefined)
|
||||
commandStore.execute.mockReset().mockResolvedValue(undefined)
|
||||
workflowService.reloadCurrentWorkflow.mockReset().mockResolvedValue(undefined)
|
||||
managerService.rebootComfyUI.mockReset().mockResolvedValue(undefined)
|
||||
runFullConflictAnalysis.mockReset().mockResolvedValue(undefined)
|
||||
useEventListener.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useApplyChanges', () => {
|
||||
it('reboots, handles reconnect, refreshes state, and closes after completion', async () => {
|
||||
const onClose = vi.fn()
|
||||
const applyChanges = useApplyChanges()
|
||||
|
||||
const applying = applyChanges.applyChanges(onClose)
|
||||
listeners.get('reconnected')?.()
|
||||
await applying
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Toast.DisableReconnectingToast',
|
||||
true
|
||||
)
|
||||
expect(managerService.rebootComfyUI).toHaveBeenCalled()
|
||||
expect(managerStore.setStale).toHaveBeenCalled()
|
||||
expect(commandStore.execute).toHaveBeenCalledWith(
|
||||
'Comfy.RefreshNodeDefinitions'
|
||||
)
|
||||
expect(workflowService.reloadCurrentWorkflow).toHaveBeenCalled()
|
||||
expect(runFullConflictAnalysis).toHaveBeenCalled()
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Toast.DisableReconnectingToast',
|
||||
false
|
||||
)
|
||||
expect(applyChanges.isRestarting.value).toBe(false)
|
||||
expect(applyChanges.isRestartCompleted.value).toBe(true)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores duplicate apply requests while restarting', async () => {
|
||||
managerService.rebootComfyUI.mockReturnValue(new Promise(() => {}))
|
||||
const applyChanges = useApplyChanges()
|
||||
|
||||
void applyChanges.applyChanges()
|
||||
await applyChanges.applyChanges()
|
||||
|
||||
expect(managerService.rebootComfyUI).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('restores the toast setting and reports timeout when reconnect never arrives', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const applyChanges = useApplyChanges()
|
||||
|
||||
await applyChanges.applyChanges()
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Toast.DisableReconnectingToast',
|
||||
false
|
||||
)
|
||||
expect(applyChanges.isRestarting.value).toBe(false)
|
||||
expect(applyChanges.isRestartCompleted.value).toBe(false)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[useApplyChanges] Reconnect timed out'
|
||||
)
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('restores state and rethrows when reboot fails', async () => {
|
||||
const onClose = vi.fn()
|
||||
const error = new Error('reboot failed')
|
||||
managerService.rebootComfyUI.mockRejectedValue(error)
|
||||
const applyChanges = useApplyChanges()
|
||||
|
||||
await expect(applyChanges.applyChanges(onClose)).rejects.toThrow(error)
|
||||
|
||||
expect(settingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Toast.DisableReconnectingToast',
|
||||
false
|
||||
)
|
||||
expect(applyChanges.isRestarting.value).toBe(false)
|
||||
expect(applyChanges.isRestartCompleted.value).toBe(false)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
@@ -10,10 +11,15 @@ import { useInstalledPacks } from '@/workbench/extensions/manager/composables/no
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import type { ConflictAcknowledgmentState } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
|
||||
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
import {
|
||||
checkAcceleratorCompatibility,
|
||||
checkOSCompatibility
|
||||
} from '@/workbench/extensions/manager/utils/systemCompatibility'
|
||||
import { checkVersionCompatibility } from '@/workbench/extensions/manager/utils/versionUtil'
|
||||
|
||||
// Mock @vueuse/core until function
|
||||
@@ -118,28 +124,28 @@ vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
describe('useConflictDetection', () => {
|
||||
let pinia: ReturnType<typeof createTestingPinia>
|
||||
|
||||
const mockComfyManagerService = {
|
||||
const mockComfyManagerService = fromPartial<
|
||||
ReturnType<typeof useComfyManagerService>
|
||||
>({
|
||||
getImportFailInfoBulk: vi.fn(),
|
||||
isLoading: ref(false),
|
||||
error: ref<string | null>(null)
|
||||
} as Partial<ReturnType<typeof useComfyManagerService>> as ReturnType<
|
||||
typeof useComfyManagerService
|
||||
>
|
||||
})
|
||||
|
||||
const mockRegistryService = {
|
||||
const mockRegistryService = fromPartial<
|
||||
ReturnType<typeof useComfyRegistryService>
|
||||
>({
|
||||
getBulkNodeVersions: vi.fn(),
|
||||
isLoading: ref(false),
|
||||
error: ref<string | null>(null)
|
||||
} as Partial<ReturnType<typeof useComfyRegistryService>> as ReturnType<
|
||||
typeof useComfyRegistryService
|
||||
>
|
||||
})
|
||||
|
||||
// Create a ref that can be modified in tests
|
||||
const mockInstalledPacksWithVersions = ref<{ id: string; version: string }[]>(
|
||||
[]
|
||||
)
|
||||
|
||||
const mockInstalledPacks = {
|
||||
const mockInstalledPacks = fromPartial<ReturnType<typeof useInstalledPacks>>({
|
||||
startFetchInstalled: vi.fn(),
|
||||
installedPacks: ref<components['schemas']['Node'][]>([]),
|
||||
installedPacksWithVersions: computed(
|
||||
@@ -148,20 +154,20 @@ describe('useConflictDetection', () => {
|
||||
isReady: ref(false),
|
||||
isLoading: ref(false),
|
||||
error: ref<unknown>(null)
|
||||
} as Partial<ReturnType<typeof useInstalledPacks>> as ReturnType<
|
||||
typeof useInstalledPacks
|
||||
>
|
||||
})
|
||||
|
||||
const mockManagerStore = {
|
||||
isPackEnabled: vi.fn()
|
||||
} as Partial<ReturnType<typeof useComfyManagerStore>> as ReturnType<
|
||||
typeof useComfyManagerStore
|
||||
>
|
||||
const mockManagerStore = fromPartial<ReturnType<typeof useComfyManagerStore>>(
|
||||
{
|
||||
isPackEnabled: vi.fn()
|
||||
}
|
||||
)
|
||||
|
||||
// Create refs that can be used to control computed properties
|
||||
let mockConflictedPackages: ConflictDetectionResult[] = []
|
||||
|
||||
const mockConflictStore = {
|
||||
const mockConflictStore = fromPartial<
|
||||
ReturnType<typeof useConflictDetectionStore>
|
||||
>({
|
||||
get hasConflicts() {
|
||||
return mockConflictedPackages.some((p) => p.has_conflict)
|
||||
},
|
||||
@@ -180,15 +186,15 @@ describe('useConflictDetection', () => {
|
||||
},
|
||||
setConflictedPackages: vi.fn(),
|
||||
clearConflicts: vi.fn()
|
||||
} as Partial<ReturnType<typeof useConflictDetectionStore>> as ReturnType<
|
||||
typeof useConflictDetectionStore
|
||||
>
|
||||
})
|
||||
|
||||
const mockIsInitialized = true
|
||||
const mockSystemStatsStore = {
|
||||
const mockSystemStatsStore = fromPartial<
|
||||
ReturnType<typeof useSystemStatsStore>
|
||||
>({
|
||||
systemStats: {
|
||||
system: {
|
||||
os: 'darwin', // sys.platform returns 'darwin' for macOS
|
||||
os: 'darwin',
|
||||
ram_total: 17179869184,
|
||||
ram_free: 8589934592,
|
||||
comfyui_version: '0.3.41',
|
||||
@@ -212,26 +218,24 @@ describe('useConflictDetection', () => {
|
||||
]
|
||||
},
|
||||
isInitialized: mockIsInitialized,
|
||||
|
||||
_customProperties: new Set<string>()
|
||||
} as Partial<ReturnType<typeof useSystemStatsStore>> as ReturnType<
|
||||
typeof useSystemStatsStore
|
||||
>
|
||||
})
|
||||
|
||||
const mockAcknowledgment = {
|
||||
checkComfyUIVersionChange: vi.fn(),
|
||||
const mockShouldShowConflictModal = ref(false)
|
||||
|
||||
const mockAcknowledgment = fromPartial<
|
||||
ReturnType<typeof useConflictAcknowledgment>
|
||||
>({
|
||||
acknowledgmentState: computed(
|
||||
() => ({}) as Partial<ConflictAcknowledgmentState>
|
||||
),
|
||||
shouldShowConflictModal: computed(() => false),
|
||||
shouldShowConflictModal: computed(() => mockShouldShowConflictModal.value),
|
||||
shouldShowRedDot: computed(() => false),
|
||||
shouldShowManagerBanner: computed(() => false),
|
||||
dismissRedDotNotification: vi.fn(),
|
||||
dismissWarningBanner: vi.fn(),
|
||||
markConflictsAsSeen: vi.fn()
|
||||
} as Partial<ReturnType<typeof useConflictAcknowledgment>> as ReturnType<
|
||||
typeof useConflictAcknowledgment
|
||||
>
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -246,6 +250,12 @@ describe('useConflictDetection', () => {
|
||||
vi.mocked(useInstalledPacks).mockReturnValue(mockInstalledPacks)
|
||||
vi.mocked(useComfyManagerStore).mockReturnValue(mockManagerStore)
|
||||
vi.mocked(useConflictDetectionStore).mockReturnValue(mockConflictStore)
|
||||
vi.mocked(useManagerState).mockReturnValue({
|
||||
isNewManagerUI: ref(true)
|
||||
} as ReturnType<typeof useManagerState>)
|
||||
vi.mocked(checkVersionCompatibility).mockReturnValue(null)
|
||||
vi.mocked(checkOSCompatibility).mockReturnValue(null)
|
||||
vi.mocked(checkAcceleratorCompatibility).mockReturnValue(null)
|
||||
|
||||
// Reset mock implementations
|
||||
vi.mocked(mockInstalledPacks.startFetchInstalled).mockResolvedValue(
|
||||
@@ -263,6 +273,7 @@ describe('useConflictDetection', () => {
|
||||
mockInstalledPacksWithVersions.value = []
|
||||
// Reset conflicted packages
|
||||
mockConflictedPackages = []
|
||||
mockShouldShowConflictModal.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -296,6 +307,21 @@ describe('useConflictDetection', () => {
|
||||
accelerator: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back when collecting system environment throws', async () => {
|
||||
vi.mocked(useSystemStatsStore).mockImplementation(() => {
|
||||
throw new Error('stats unavailable')
|
||||
})
|
||||
|
||||
const { collectSystemEnvironment } = useConflictDetection()
|
||||
|
||||
await expect(collectSystemEnvironment()).resolves.toEqual({
|
||||
comfyui_version: undefined,
|
||||
frontend_version: undefined,
|
||||
os: undefined,
|
||||
accelerator: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('conflict detection', () => {
|
||||
@@ -446,6 +472,240 @@ describe('useConflictDetection', () => {
|
||||
required_value: 'Import error'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses fallbacks for pending packages and missing registry metadata', async () => {
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacks.installedPacks.value = [
|
||||
{ id: 'pending-pack' } as components['schemas']['Node']
|
||||
]
|
||||
mockInstalledPacksWithVersions.value = [
|
||||
{ id: 'missing-pack', version: '1.0.0' },
|
||||
{ id: 'pending-pack', version: '2.0.0' }
|
||||
]
|
||||
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
|
||||
node_versions: [
|
||||
{
|
||||
status: 'success' as const,
|
||||
identifier: { node_id: 'pending-pack', version: '2.0.0' },
|
||||
node_version: {
|
||||
status: 'NodeVersionStatusPending' as const,
|
||||
version: '2.0.0',
|
||||
publisher_id: 'publisher',
|
||||
node_id: 'pending-pack',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
supported_comfyui_version: undefined,
|
||||
supported_comfyui_frontend_version: undefined,
|
||||
supported_os: undefined,
|
||||
supported_accelerators: undefined
|
||||
} as components['schemas']['NodeVersion']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { runFullConflictAnalysis } = useConflictDetection()
|
||||
const result = await runFullConflictAnalysis()
|
||||
|
||||
expect(result.results).toHaveLength(1)
|
||||
expect(result.results[0]).toMatchObject({
|
||||
package_id: 'pending-pack',
|
||||
has_conflict: true
|
||||
})
|
||||
expect(result.results[0].conflicts).toContainEqual({
|
||||
type: 'pending',
|
||||
current_value: 'installed',
|
||||
required_value: 'not_pending'
|
||||
})
|
||||
})
|
||||
|
||||
it('records compatibility conflicts from version, OS, and accelerator checks', async () => {
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacks.installedPacks.value = [
|
||||
{
|
||||
id: 'compat-pack',
|
||||
name: 'Compatibility Pack'
|
||||
} as components['schemas']['Node']
|
||||
]
|
||||
mockInstalledPacksWithVersions.value = [
|
||||
{ id: 'compat-pack', version: '1.0.0' }
|
||||
]
|
||||
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
|
||||
node_versions: [
|
||||
{
|
||||
status: 'success' as const,
|
||||
identifier: { node_id: 'compat-pack', version: '1.0.0' },
|
||||
node_version: {
|
||||
status: 'NodeVersionStatusActive' as const,
|
||||
version: '1.0.0',
|
||||
publisher_id: 'publisher',
|
||||
node_id: 'compat-pack',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
supported_comfyui_version: '>=9.0.0',
|
||||
supported_comfyui_frontend_version: '>=9.0.0',
|
||||
supported_os: ['Windows'],
|
||||
supported_accelerators: ['CUDA']
|
||||
} as components['schemas']['NodeVersion']
|
||||
}
|
||||
]
|
||||
})
|
||||
vi.mocked(checkVersionCompatibility).mockImplementation((type) => ({
|
||||
type,
|
||||
current_value: type,
|
||||
required_value: '>=9.0.0'
|
||||
}))
|
||||
vi.mocked(checkOSCompatibility).mockReturnValue({
|
||||
type: 'os',
|
||||
current_value: 'macOS',
|
||||
required_value: 'Windows'
|
||||
})
|
||||
vi.mocked(checkAcceleratorCompatibility).mockReturnValue({
|
||||
type: 'accelerator',
|
||||
current_value: 'Metal',
|
||||
required_value: 'CUDA'
|
||||
})
|
||||
|
||||
const { runFullConflictAnalysis } = useConflictDetection()
|
||||
const result = await runFullConflictAnalysis()
|
||||
|
||||
expect(result.results[0].conflicts).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
type: 'comfyui_version',
|
||||
current_value: 'comfyui_version',
|
||||
required_value: '>=9.0.0'
|
||||
},
|
||||
{
|
||||
type: 'frontend_version',
|
||||
current_value: 'frontend_version',
|
||||
required_value: '>=9.0.0'
|
||||
},
|
||||
{ type: 'os', current_value: 'macOS', required_value: 'Windows' },
|
||||
{
|
||||
type: 'accelerator',
|
||||
current_value: 'Metal',
|
||||
required_value: 'CUDA'
|
||||
}
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('returns no results when installed packs are not ready', async () => {
|
||||
mockInstalledPacks.isReady.value = false
|
||||
mockInstalledPacks.installedPacks.value = [
|
||||
{ id: 'not-ready' } as components['schemas']['Node']
|
||||
]
|
||||
mockInstalledPacksWithVersions.value = [
|
||||
{ id: 'not-ready', version: '1.0.0' }
|
||||
]
|
||||
|
||||
const { runFullConflictAnalysis } = useConflictDetection()
|
||||
const result = await runFullConflictAnalysis()
|
||||
|
||||
expect(result.results).toEqual([])
|
||||
expect(mockConflictStore.clearConflicts).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues when registry bulk lookup fails', async () => {
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacks.installedPacks.value = [
|
||||
{ id: 'fallback-pack' } as components['schemas']['Node']
|
||||
]
|
||||
mockInstalledPacksWithVersions.value = [
|
||||
{ id: 'fallback-pack', version: '1.0.0' }
|
||||
]
|
||||
vi.mocked(mockRegistryService.getBulkNodeVersions).mockRejectedValue(
|
||||
new Error('registry down')
|
||||
)
|
||||
|
||||
const { runFullConflictAnalysis } = useConflictDetection()
|
||||
const result = await runFullConflictAnalysis()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.results).toEqual([
|
||||
{
|
||||
package_id: 'fallback-pack',
|
||||
package_name: 'fallback-pack',
|
||||
has_conflict: false,
|
||||
conflicts: [],
|
||||
is_compatible: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('continues when import failure lookup throws', async () => {
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacks.installedPacks.value = [
|
||||
{ id: 'clean-pack' } as components['schemas']['Node']
|
||||
]
|
||||
mockInstalledPacksWithVersions.value = [
|
||||
{ id: 'clean-pack', version: '1.0.0' }
|
||||
]
|
||||
vi.mocked(
|
||||
mockComfyManagerService.getImportFailInfoBulk
|
||||
).mockRejectedValue(new Error('manager down'))
|
||||
|
||||
const { runFullConflictAnalysis } = useConflictDetection()
|
||||
const result = await runFullConflictAnalysis()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.results).toEqual([
|
||||
{
|
||||
package_id: 'clean-pack',
|
||||
package_name: 'clean-pack',
|
||||
has_conflict: false,
|
||||
conflicts: [],
|
||||
is_compatible: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('uses unknown import error text when failure details omit messages', async () => {
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacksWithVersions.value = [
|
||||
{ id: 'unknown-fail-pack', version: '1.0.0' }
|
||||
]
|
||||
vi.mocked(
|
||||
mockComfyManagerService.getImportFailInfoBulk
|
||||
).mockResolvedValue({
|
||||
'unknown-fail-pack': {},
|
||||
'clean-pack': null
|
||||
})
|
||||
|
||||
const { runFullConflictAnalysis } = useConflictDetection()
|
||||
const result = await runFullConflictAnalysis()
|
||||
|
||||
expect(result.results[0].conflicts).toContainEqual({
|
||||
type: 'import_failed',
|
||||
current_value: 'Unknown import error',
|
||||
required_value: 'Unknown import error'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an in-progress response when analysis is already running', async () => {
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacks.installedPacks.value = [
|
||||
{ id: 'slow-pack' } as components['schemas']['Node']
|
||||
]
|
||||
mockInstalledPacksWithVersions.value = [
|
||||
{ id: 'slow-pack', version: '1.0.0' }
|
||||
]
|
||||
let resolveFetch: () => void = () => {}
|
||||
vi.mocked(mockInstalledPacks.startFetchInstalled).mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveFetch = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const { runFullConflictAnalysis } = useConflictDetection()
|
||||
const firstRun = runFullConflictAnalysis()
|
||||
const secondRun = await runFullConflictAnalysis()
|
||||
resolveFetch()
|
||||
await firstRun
|
||||
|
||||
expect(secondRun).toMatchObject({
|
||||
success: false,
|
||||
error_message: 'Already detecting conflicts'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed properties', () => {
|
||||
@@ -491,5 +751,151 @@ describe('useConflictDetection', () => {
|
||||
])
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('skips initialization when the new manager UI is disabled', async () => {
|
||||
vi.mocked(useManagerState).mockReturnValue({
|
||||
isNewManagerUI: ref(false)
|
||||
} as ReturnType<typeof useManagerState>)
|
||||
const { initializeConflictDetection } = useConflictDetection()
|
||||
|
||||
await initializeConflictDetection()
|
||||
|
||||
expect(mockInstalledPacks.startFetchInstalled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores initialization errors', async () => {
|
||||
vi.mocked(useSystemStatsStore).mockImplementation(() => {
|
||||
throw new Error('stats unavailable')
|
||||
})
|
||||
const { initializeConflictDetection } = useConflictDetection()
|
||||
|
||||
await expect(initializeConflictDetection()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal gating', () => {
|
||||
it('shows the modal after update when conflicts exist and acknowledgment allows it', async () => {
|
||||
mockConflictedPackages = [
|
||||
{
|
||||
package_id: 'conflict-pack',
|
||||
package_name: 'Conflict Pack',
|
||||
has_conflict: true,
|
||||
is_compatible: false,
|
||||
conflicts: []
|
||||
}
|
||||
]
|
||||
mockShouldShowConflictModal.value = true
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacksWithVersions.value = []
|
||||
const { shouldShowConflictModalAfterUpdate } = useConflictDetection()
|
||||
|
||||
await expect(shouldShowConflictModalAfterUpdate()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the modal hidden when acknowledgment blocks it', async () => {
|
||||
mockConflictedPackages = [
|
||||
{
|
||||
package_id: 'conflict-pack',
|
||||
package_name: 'Conflict Pack',
|
||||
has_conflict: true,
|
||||
is_compatible: false,
|
||||
conflicts: []
|
||||
}
|
||||
]
|
||||
mockShouldShowConflictModal.value = false
|
||||
mockInstalledPacks.isReady.value = true
|
||||
mockInstalledPacksWithVersions.value = []
|
||||
const { shouldShowConflictModalAfterUpdate } = useConflictDetection()
|
||||
|
||||
await expect(shouldShowConflictModalAfterUpdate()).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('node compatibility', () => {
|
||||
it('reports compatibility conflicts for registry nodes', async () => {
|
||||
const { collectSystemEnvironment, checkNodeCompatibility } =
|
||||
useConflictDetection()
|
||||
await collectSystemEnvironment()
|
||||
vi.mocked(checkOSCompatibility).mockReturnValue({
|
||||
type: 'os',
|
||||
current_value: 'macOS',
|
||||
required_value: 'Windows'
|
||||
})
|
||||
vi.mocked(checkAcceleratorCompatibility).mockReturnValue({
|
||||
type: 'accelerator',
|
||||
current_value: 'Metal',
|
||||
required_value: 'CUDA'
|
||||
})
|
||||
vi.mocked(checkVersionCompatibility).mockImplementation((type) => ({
|
||||
type,
|
||||
current_value: type,
|
||||
required_value: '>=9.0.0'
|
||||
}))
|
||||
|
||||
const result = checkNodeCompatibility({
|
||||
id: 'node-pack',
|
||||
status: 'NodeStatusBanned',
|
||||
supported_os: ['Windows'],
|
||||
supported_accelerators: ['CUDA'],
|
||||
supported_comfyui_version: '>=9.0.0',
|
||||
supported_comfyui_frontend_version: '>=9.0.0'
|
||||
} as components['schemas']['Node'])
|
||||
|
||||
expect(result).toEqual({
|
||||
hasConflict: true,
|
||||
conflicts: expect.arrayContaining([
|
||||
{ type: 'os', current_value: 'macOS', required_value: 'Windows' },
|
||||
{
|
||||
type: 'accelerator',
|
||||
current_value: 'Metal',
|
||||
required_value: 'CUDA'
|
||||
},
|
||||
{
|
||||
type: 'comfyui_version',
|
||||
current_value: 'comfyui_version',
|
||||
required_value: '>=9.0.0'
|
||||
},
|
||||
{
|
||||
type: 'frontend_version',
|
||||
current_value: 'frontend_version',
|
||||
required_value: '>=9.0.0'
|
||||
},
|
||||
{
|
||||
type: 'banned',
|
||||
current_value: 'installed',
|
||||
required_value: 'not_banned'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('reports pending version nodes as incompatible', () => {
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
|
||||
const result = checkNodeCompatibility({
|
||||
node_id: 'node-pack',
|
||||
version: '1.0.0',
|
||||
publisher_id: 'publisher',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
status: 'NodeVersionStatusPending'
|
||||
} as components['schemas']['NodeVersion'])
|
||||
|
||||
expect(result.conflicts).toContainEqual({
|
||||
type: 'pending',
|
||||
current_value: 'installed',
|
||||
required_value: 'not_pending'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports compatible registry nodes without conflicts', () => {
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
|
||||
expect(
|
||||
checkNodeCompatibility({
|
||||
id: 'node-pack',
|
||||
status: 'NodeStatusActive'
|
||||
} as components['schemas']['Node'])
|
||||
).toEqual({ hasConflict: false, conflicts: [] })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import type * as VueUse from '@vueuse/core'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import { useManagerDisplayPacks } from '@/workbench/extensions/manager/composables/useManagerDisplayPacks'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { state } = vi.hoisted(() => ({
|
||||
state: {
|
||||
installed: [] as NodePack[],
|
||||
workflow: [] as NodePack[],
|
||||
installedLoading: false,
|
||||
workflowLoading: false,
|
||||
installedReady: true,
|
||||
workflowReady: true,
|
||||
startFetchInstalled: vi.fn(),
|
||||
startFetchWorkflowPacks: vi.fn(),
|
||||
installedIds: new Set<string>(),
|
||||
installedVersions: {} as Record<string, string>,
|
||||
conflicts: [] as { package_id: string }[]
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (orig) => ({
|
||||
...(await orig<typeof VueUse>()),
|
||||
whenever: (source: unknown, callback?: () => void) => {
|
||||
if (typeof source === 'function' && source() && callback) {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/services/gateway/registrySearchGateway', () => ({
|
||||
useRegistrySearchGateway: () => ({
|
||||
getSortValue: (pack: NodePack, field: string) =>
|
||||
(pack as Record<string, unknown>)[field],
|
||||
getSortableFields: () => [{ id: 'name', direction: 'asc' }]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/nodePack/useInstalledPacks',
|
||||
() => ({
|
||||
useInstalledPacks: () => ({
|
||||
startFetchInstalled: state.startFetchInstalled,
|
||||
filterInstalledPack: (packs: NodePack[]) =>
|
||||
packs.filter((p) => state.installedIds.has(p.id ?? '')),
|
||||
installedPacks: ref(state.installed),
|
||||
isLoading: ref(state.installedLoading),
|
||||
isReady: ref(state.installedReady)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks',
|
||||
() => ({
|
||||
useWorkflowPacks: () => ({
|
||||
startFetchWorkflowPacks: state.startFetchWorkflowPacks,
|
||||
filterWorkflowPack: (packs: NodePack[]) => packs,
|
||||
workflowPacks: ref(state.workflow),
|
||||
isLoading: ref(state.workflowLoading),
|
||||
isReady: ref(state.workflowReady)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: () => ({
|
||||
isPackInstalled: (id: string | undefined) =>
|
||||
state.installedIds.has(id ?? ''),
|
||||
getInstalledPackVersion: (id: string) => state.installedVersions[id]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/stores/conflictDetectionStore', () => ({
|
||||
useConflictDetectionStore: () => ({
|
||||
get conflictedPackages() {
|
||||
return state.conflicts
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
function pack(id: string, latestVersion?: string): NodePack {
|
||||
return fromPartial<NodePack>({
|
||||
id,
|
||||
name: id,
|
||||
latest_version: latestVersion ? { version: latestVersion } : undefined
|
||||
})
|
||||
}
|
||||
|
||||
function display(
|
||||
tab: ManagerTab,
|
||||
searchResults: NodePack[] = [],
|
||||
query = '',
|
||||
sortField = ''
|
||||
) {
|
||||
return useManagerDisplayPacks(
|
||||
ref(tab),
|
||||
ref(searchResults),
|
||||
ref(query),
|
||||
ref(sortField)
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.installed = []
|
||||
state.workflow = []
|
||||
state.installedLoading = false
|
||||
state.workflowLoading = false
|
||||
state.installedReady = true
|
||||
state.workflowReady = true
|
||||
state.startFetchInstalled.mockReset()
|
||||
state.startFetchWorkflowPacks.mockReset()
|
||||
state.installedIds = new Set()
|
||||
state.installedVersions = {}
|
||||
state.conflicts = []
|
||||
})
|
||||
|
||||
describe('useManagerDisplayPacks', () => {
|
||||
it('All tab returns the raw search results', () => {
|
||||
const results = [pack('a'), pack('b')]
|
||||
expect(display(ManagerTab.All, results).displayPacks.value).toEqual(results)
|
||||
})
|
||||
|
||||
it('NotInstalled tab excludes installed packs', () => {
|
||||
state.installedIds = new Set(['a'])
|
||||
const { displayPacks } = display(ManagerTab.NotInstalled, [
|
||||
pack('a'),
|
||||
pack('b')
|
||||
])
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('AllInstalled tab shows installed packs when not searching', () => {
|
||||
state.installed = [pack('x'), pack('y')]
|
||||
const { displayPacks } = display(ManagerTab.AllInstalled)
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['x', 'y'])
|
||||
})
|
||||
|
||||
it('UpdateAvailable tab keeps only installed packs with a newer latest version', () => {
|
||||
state.installedIds = new Set(['old', 'current', 'nightly'])
|
||||
state.installedVersions = {
|
||||
old: '1.0.0',
|
||||
current: '2.0.0',
|
||||
nightly: 'not-semver'
|
||||
}
|
||||
state.installed = [
|
||||
pack('old', '1.2.0'),
|
||||
pack('current', '2.0.0'),
|
||||
pack('nightly', '9.9.9'),
|
||||
pack('uninstalled', '5.0.0')
|
||||
]
|
||||
const { displayPacks } = display(ManagerTab.UpdateAvailable)
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['old'])
|
||||
})
|
||||
|
||||
it('Conflicting tab keeps packs flagged by the conflict store', () => {
|
||||
state.installed = [pack('a'), pack('b')]
|
||||
state.conflicts = [{ package_id: 'b' }]
|
||||
const { displayPacks } = display(ManagerTab.Conflicting)
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('Missing tab returns workflow packs that are not installed', () => {
|
||||
state.workflow = [pack('a'), pack('b')]
|
||||
state.installedIds = new Set(['a'])
|
||||
const { displayPacks, missingNodePacks } = display(ManagerTab.Missing)
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['b'])
|
||||
expect(missingNodePacks.value.map((p) => p.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('Unresolved tab is always empty', () => {
|
||||
expect(
|
||||
display(ManagerTab.Unresolved, [pack('a')]).displayPacks.value
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('reports loading state scoped to the active tab group', () => {
|
||||
state.installedLoading = true
|
||||
state.workflowLoading = false
|
||||
expect(display(ManagerTab.AllInstalled).isLoading.value).toBe(true)
|
||||
expect(display(ManagerTab.All).isLoading.value).toBe(false)
|
||||
|
||||
state.installedLoading = false
|
||||
state.workflowLoading = true
|
||||
expect(display(ManagerTab.Workflow).isLoading.value).toBe(true)
|
||||
expect(display(ManagerTab.Missing).isLoading.value).toBe(true)
|
||||
})
|
||||
|
||||
it('fetches installed packs when an installed tab is selected and not ready', () => {
|
||||
state.installedReady = false
|
||||
display(ManagerTab.AllInstalled)
|
||||
|
||||
expect(state.startFetchInstalled).toHaveBeenCalledTimes(1)
|
||||
expect(state.startFetchWorkflowPacks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches workflow and installed packs for missing workflow dependencies', () => {
|
||||
state.installedReady = false
|
||||
state.workflowReady = false
|
||||
display(ManagerTab.Missing)
|
||||
|
||||
expect(state.startFetchInstalled).toHaveBeenCalledTimes(1)
|
||||
expect(state.startFetchWorkflowPacks).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('filters search results to installed packs on the AllInstalled tab while searching', () => {
|
||||
state.installedIds = new Set(['a'])
|
||||
const { displayPacks } = display(
|
||||
ManagerTab.AllInstalled,
|
||||
[pack('a'), pack('b')],
|
||||
'query'
|
||||
)
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('filters searched update and conflict tabs before applying tab rules', () => {
|
||||
state.installedIds = new Set(['old', 'conflict'])
|
||||
state.installedVersions = {
|
||||
old: '1.0.0',
|
||||
conflict: '1.0.0'
|
||||
}
|
||||
state.conflicts = [{ package_id: 'conflict' }]
|
||||
const results = [
|
||||
pack('old', '2.0.0'),
|
||||
pack('current', '1.0.0'),
|
||||
pack('conflict', '1.0.0')
|
||||
]
|
||||
|
||||
expect(
|
||||
display(
|
||||
ManagerTab.UpdateAvailable,
|
||||
results,
|
||||
'query'
|
||||
).displayPacks.value.map((p) => p.id)
|
||||
).toEqual(['old'])
|
||||
expect(
|
||||
display(ManagerTab.Conflicting, results, 'query').displayPacks.value.map(
|
||||
(p) => p.id
|
||||
)
|
||||
).toEqual(['conflict'])
|
||||
})
|
||||
|
||||
it('filters workflow search results on the Workflow tab while searching', () => {
|
||||
const { displayPacks } = display(
|
||||
ManagerTab.Workflow,
|
||||
[pack('a'), pack('b')],
|
||||
'query'
|
||||
)
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('filters searched missing workflow packs to not-installed packs', () => {
|
||||
state.installedIds = new Set(['a'])
|
||||
const { displayPacks } = display(
|
||||
ManagerTab.Missing,
|
||||
[pack('a'), pack('b')],
|
||||
'query'
|
||||
)
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('falls back to search results for unknown tabs', () => {
|
||||
const results = [pack('a')]
|
||||
expect(
|
||||
display('unknown' as ManagerTab, results).displayPacks.value
|
||||
).toEqual(results)
|
||||
})
|
||||
|
||||
it('sorts installed packs by the configured field', () => {
|
||||
state.installed = [pack('b'), pack('a'), pack('c')]
|
||||
const { displayPacks } = display(ManagerTab.AllInstalled, [], '', 'name')
|
||||
expect(displayPacks.value.map((p) => p.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
})
|
||||
@@ -5,12 +5,23 @@ import { ref } from 'vue'
|
||||
import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue'
|
||||
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
||||
|
||||
// Mock the app API
|
||||
const mockAppApi = vi.hoisted(() => ({
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
mockAppApi.listeners.set(type, listener)
|
||||
}),
|
||||
listeners: new Map<string, EventListener>(),
|
||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (mockAppApi.listeners.get(type) === listener) {
|
||||
mockAppApi.listeners.delete(type)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
api: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addEventListener: mockAppApi.addEventListener,
|
||||
removeEventListener: mockAppApi.removeEventListener,
|
||||
clientId: 'test-client-id'
|
||||
}
|
||||
}
|
||||
@@ -21,6 +32,43 @@ type ManagerTaskHistory = Record<
|
||||
components['schemas']['TaskHistoryItem']
|
||||
>
|
||||
type ManagerTaskQueue = components['schemas']['TaskStateMessage']
|
||||
type QueueTaskItem = components['schemas']['QueueTaskItem']
|
||||
|
||||
function createQueueTask(
|
||||
uiId: string,
|
||||
clientId = 'test-client-id'
|
||||
): QueueTaskItem {
|
||||
return {
|
||||
ui_id: uiId,
|
||||
client_id: clientId,
|
||||
kind: 'install',
|
||||
params: {
|
||||
id: uiId,
|
||||
version: '1.0.0',
|
||||
selected_version: '1.0.0',
|
||||
mode: 'remote',
|
||||
channel: 'default'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createTaskState(
|
||||
overrides: Partial<ManagerTaskQueue> = {}
|
||||
): ManagerTaskQueue {
|
||||
return {
|
||||
history: {},
|
||||
running_queue: [],
|
||||
pending_queue: [],
|
||||
installed_packs: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchManagerEvent(type: string, detail: unknown) {
|
||||
const listener = mockAppApi.listeners.get(type)
|
||||
if (!listener) throw new Error(`Missing listener for ${type}`)
|
||||
listener(new CustomEvent(type, { detail }))
|
||||
}
|
||||
|
||||
describe('useManagerQueue', () => {
|
||||
let taskHistory: Ref<ManagerTaskHistory>
|
||||
@@ -44,10 +92,12 @@ describe('useManagerQueue', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppApi.listeners.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppApi.listeners.clear()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
@@ -234,5 +284,82 @@ describe('useManagerQueue', () => {
|
||||
// installedPacks should remain unchanged
|
||||
expect(installedPacks.value).toEqual({})
|
||||
})
|
||||
|
||||
it('updates task state from task started websocket events', () => {
|
||||
const queue = createManagerQueue()
|
||||
|
||||
dispatchManagerEvent('cm-task-started', {
|
||||
state: createTaskState({
|
||||
running_queue: [createQueueTask('running-task')],
|
||||
pending_queue: [createQueueTask('other-client-task', 'other-client')]
|
||||
})
|
||||
})
|
||||
|
||||
expect(taskQueue.value.running_queue).toEqual([
|
||||
createQueueTask('running-task')
|
||||
])
|
||||
expect(taskQueue.value.pending_queue).toEqual([])
|
||||
expect(queue.currentQueueLength.value).toBe(1)
|
||||
expect(queue.isProcessing.value).toBe(true)
|
||||
expect(queue.allTasksDone.value).toBe(false)
|
||||
})
|
||||
|
||||
it('updates task state from task completed websocket events', () => {
|
||||
const queue = createManagerQueue()
|
||||
|
||||
dispatchManagerEvent('cm-task-completed', {
|
||||
state: createTaskState({
|
||||
history: {
|
||||
completed: {
|
||||
ui_id: 'completed',
|
||||
client_id: 'test-client-id',
|
||||
kind: 'install',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
result: 'success'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(queue.historyCount.value).toBe(1)
|
||||
expect(taskHistory.value).toHaveProperty('completed')
|
||||
})
|
||||
|
||||
it('ignores malformed websocket events', () => {
|
||||
const queue = createManagerQueue()
|
||||
|
||||
dispatchManagerEvent('cm-task-started', {
|
||||
state: undefined
|
||||
})
|
||||
dispatchManagerEvent('cm-task-completed', {})
|
||||
|
||||
expect(queue.currentQueueLength.value).toBe(0)
|
||||
expect(queue.historyCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('removes websocket listeners and resets local flags on cleanup', () => {
|
||||
const queue = createManagerQueue()
|
||||
queue.isLoading.value = true
|
||||
queue.updateTaskState(
|
||||
createTaskState({
|
||||
running_queue: [createQueueTask('running-task')]
|
||||
})
|
||||
)
|
||||
|
||||
queue.cleanup()
|
||||
|
||||
expect(queue.isLoading.value).toBe(false)
|
||||
expect(queue.isProcessing.value).toBe(false)
|
||||
expect(mockAppApi.removeEventListener).toHaveBeenCalledWith(
|
||||
'cm-task-completed',
|
||||
expect.any(Function),
|
||||
undefined
|
||||
)
|
||||
expect(mockAppApi.removeEventListener).toHaveBeenCalledWith(
|
||||
'cm-task-started',
|
||||
expect.any(Function),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
__resetIncompatibleToastGuard,
|
||||
useManagerState
|
||||
} from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
// Mock dependencies that are not stores
|
||||
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
|
||||
@@ -31,24 +32,38 @@ vi.mock('@/composables/useFeatureFlags', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
commandExecuteMock,
|
||||
managerDialogHideMock,
|
||||
managerDialogShowMock,
|
||||
settingsHideMock,
|
||||
settingsShowAboutMock,
|
||||
settingsShowMock,
|
||||
toastAddMock
|
||||
} = vi.hoisted(() => ({
|
||||
commandExecuteMock: vi.fn(),
|
||||
managerDialogHideMock: vi.fn(),
|
||||
managerDialogShowMock: vi.fn(),
|
||||
settingsHideMock: vi.fn(),
|
||||
settingsShowAboutMock: vi.fn(),
|
||||
settingsShowMock: vi.fn(),
|
||||
toastAddMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: vi.fn(() => ({
|
||||
show: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
showAbout: vi.fn()
|
||||
show: settingsShowMock,
|
||||
hide: settingsHideMock,
|
||||
showAbout: settingsShowAboutMock
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: vi.fn(() => ({
|
||||
execute: vi.fn()
|
||||
execute: commandExecuteMock
|
||||
}))
|
||||
}))
|
||||
|
||||
const { toastAddMock } = vi.hoisted(() => ({
|
||||
toastAddMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({
|
||||
add: toastAddMock
|
||||
@@ -56,12 +71,10 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerDialog', () => {
|
||||
const show = vi.fn()
|
||||
const hide = vi.fn()
|
||||
return {
|
||||
useManagerDialog: vi.fn(() => ({
|
||||
show,
|
||||
hide
|
||||
show: managerDialogShowMock,
|
||||
hide: managerDialogHideMock
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -84,6 +97,17 @@ const systemStatsFixture = (argv: string[]) => ({
|
||||
devices: []
|
||||
})
|
||||
|
||||
const enabledManagerStats = () =>
|
||||
systemStatsFixture(['python', 'main.py', '--enable-manager'])
|
||||
|
||||
const legacyManagerStats = () =>
|
||||
systemStatsFixture([
|
||||
'python',
|
||||
'main.py',
|
||||
'--enable-manager',
|
||||
'--enable-manager-legacy-ui'
|
||||
])
|
||||
|
||||
/**
|
||||
* Mocks the two server feature flags queried by useManagerState.
|
||||
* `supports_v4` → `extension.manager.supports_v4`
|
||||
@@ -138,12 +162,7 @@ describe('useManagerState', () => {
|
||||
|
||||
it('should return LEGACY_UI state when --enable-manager-legacy-ui is present', () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: systemStatsFixture([
|
||||
'python',
|
||||
'main.py',
|
||||
'--enable-manager',
|
||||
'--enable-manager-legacy-ui'
|
||||
]),
|
||||
systemStats: legacyManagerStats(),
|
||||
isInitialized: true
|
||||
})
|
||||
|
||||
@@ -237,12 +256,28 @@ describe('useManagerState', () => {
|
||||
const managerState = useManagerState()
|
||||
expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED)
|
||||
})
|
||||
|
||||
it('should disable manager for unexpected server support flag values', () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: systemStatsFixture([
|
||||
'python',
|
||||
'main.py',
|
||||
'--enable-manager'
|
||||
]),
|
||||
isInitialized: true
|
||||
})
|
||||
vi.mocked(api.getServerFeature).mockImplementation((name: string) => {
|
||||
if (name === 'extension.manager.supports_v4') return 'unexpected'
|
||||
if (name === 'extension.manager.supports_csrf_post') return true
|
||||
return undefined
|
||||
})
|
||||
|
||||
const managerState = useManagerState()
|
||||
expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED)
|
||||
})
|
||||
})
|
||||
|
||||
describe('INCOMPATIBLE state (missing supports_csrf_post)', () => {
|
||||
const enabledManagerStats = () =>
|
||||
systemStatsFixture(['python', 'main.py', '--enable-manager'])
|
||||
|
||||
it('returns INCOMPATIBLE when server supports v4 but csrf_post is false', () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: enabledManagerStats(),
|
||||
@@ -369,9 +404,6 @@ describe('useManagerState', () => {
|
||||
})
|
||||
|
||||
describe('helper properties', () => {
|
||||
const enabledManagerStats = () =>
|
||||
systemStatsFixture(['python', 'main.py', '--enable-manager'])
|
||||
|
||||
it('isManagerEnabled should return true when state is not DISABLED / INCOMPATIBLE', () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: enabledManagerStats(),
|
||||
@@ -412,12 +444,7 @@ describe('useManagerState', () => {
|
||||
|
||||
it('isLegacyManagerUI should return true when state is LEGACY_UI', () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: systemStatsFixture([
|
||||
'python',
|
||||
'main.py',
|
||||
'--enable-manager',
|
||||
'--enable-manager-legacy-ui'
|
||||
]),
|
||||
systemStats: legacyManagerStats(),
|
||||
isInitialized: true
|
||||
})
|
||||
|
||||
@@ -453,4 +480,119 @@ describe('useManagerState', () => {
|
||||
expect(managerState.shouldShowManagerButtons.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openManager', () => {
|
||||
it('opens extension settings when manager is disabled', async () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: systemStatsFixture(['python', 'main.py']),
|
||||
isInitialized: true
|
||||
})
|
||||
|
||||
const managerState = useManagerState()
|
||||
await managerState.openManager()
|
||||
|
||||
expect(settingsShowMock).toHaveBeenCalledWith('extension')
|
||||
})
|
||||
|
||||
it('executes the default legacy manager command', async () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: legacyManagerStats(),
|
||||
isInitialized: true
|
||||
})
|
||||
|
||||
const managerState = useManagerState()
|
||||
await managerState.openManager()
|
||||
|
||||
expect(commandExecuteMock).toHaveBeenCalledWith(
|
||||
'Comfy.Manager.Menu.ToggleVisibility'
|
||||
)
|
||||
})
|
||||
|
||||
it('executes a custom legacy manager command', async () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: legacyManagerStats(),
|
||||
isInitialized: true
|
||||
})
|
||||
|
||||
const managerState = useManagerState()
|
||||
await managerState.openManager({ legacyCommand: 'Custom.Manager.Open' })
|
||||
|
||||
expect(commandExecuteMock).toHaveBeenCalledWith('Custom.Manager.Open')
|
||||
})
|
||||
|
||||
it('shows a toast when the legacy manager command is unavailable', async () => {
|
||||
commandExecuteMock.mockRejectedValueOnce(new Error('missing command'))
|
||||
systemStatsStore.$patch({
|
||||
systemStats: legacyManagerStats(),
|
||||
isInitialized: true
|
||||
})
|
||||
|
||||
const managerState = useManagerState()
|
||||
await managerState.openManager()
|
||||
|
||||
expect(toastAddMock).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'manager.legacyMenuNotAvailable'
|
||||
})
|
||||
expect(settingsShowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to extension settings when legacy errors suppress the toast', async () => {
|
||||
commandExecuteMock.mockRejectedValueOnce(new Error('missing command'))
|
||||
systemStatsStore.$patch({
|
||||
systemStats: legacyManagerStats(),
|
||||
isInitialized: true
|
||||
})
|
||||
|
||||
const managerState = useManagerState()
|
||||
await managerState.openManager({ showToastOnLegacyError: false })
|
||||
|
||||
expect(toastAddMock).not.toHaveBeenCalled()
|
||||
expect(settingsShowMock).toHaveBeenCalledWith('extension')
|
||||
})
|
||||
|
||||
it('opens the new manager dialog with initial routing options', async () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: enabledManagerStats(),
|
||||
isInitialized: true
|
||||
})
|
||||
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
|
||||
supports_manager_v4_ui: true
|
||||
})
|
||||
mockServerFeatures({ supports_v4: true, supports_csrf_post: true })
|
||||
|
||||
const managerState = useManagerState()
|
||||
await managerState.openManager({
|
||||
initialTab: ManagerTab.AllInstalled,
|
||||
initialPackId: 'pack-1'
|
||||
})
|
||||
|
||||
expect(managerDialogShowMock).toHaveBeenCalledWith(
|
||||
ManagerTab.AllInstalled,
|
||||
'pack-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows a legacy-only error instead of opening the new manager', async () => {
|
||||
systemStatsStore.$patch({
|
||||
systemStats: enabledManagerStats(),
|
||||
isInitialized: true
|
||||
})
|
||||
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
|
||||
supports_manager_v4_ui: true
|
||||
})
|
||||
mockServerFeatures({ supports_v4: true, supports_csrf_post: true })
|
||||
|
||||
const managerState = useManagerState()
|
||||
await managerState.openManager({ isLegacyOnly: true })
|
||||
|
||||
expect(toastAddMock).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'manager.legacyMenuNotAvailable'
|
||||
})
|
||||
expect(managerDialogShowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SortableField } from '@/types/searchServiceTypes'
|
||||
import { useRegistrySearch } from '@/workbench/extensions/manager/composables/useRegistrySearch'
|
||||
|
||||
const mockSearchGateway = vi.hoisted(() => ({
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(
|
||||
(pack: Record<string, unknown>, field: string) => pack[field]
|
||||
),
|
||||
getSortableFields: vi.fn((): SortableField[] => [])
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const vue = await import('vue')
|
||||
return {
|
||||
watchDebounced: (
|
||||
source: unknown,
|
||||
callback: () => void,
|
||||
options?: { immediate?: boolean }
|
||||
) => {
|
||||
if (options?.immediate) callback()
|
||||
return vue.watch(source as Parameters<typeof vue.watch>[0], callback)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/services/gateway/registrySearchGateway', () => ({
|
||||
useRegistrySearchGateway: () => mockSearchGateway
|
||||
}))
|
||||
|
||||
function pack(name: string, downloads = 0) {
|
||||
return { id: name, name, downloads }
|
||||
}
|
||||
|
||||
async function flushSearch() {
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('useRegistrySearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSearchGateway.searchPacks.mockResolvedValue({
|
||||
nodePacks: [],
|
||||
querySuggestions: []
|
||||
})
|
||||
mockSearchGateway.getSortableFields.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('runs the initial pack search with default paging and attributes', async () => {
|
||||
mockSearchGateway.searchPacks.mockResolvedValueOnce({
|
||||
nodePacks: [pack('alpha')],
|
||||
querySuggestions: [{ query: 'alpha' }]
|
||||
})
|
||||
|
||||
const search = useRegistrySearch({ initialSearchQuery: 'alp' })
|
||||
await flushSearch()
|
||||
|
||||
expect(mockSearchGateway.searchPacks).toHaveBeenCalledWith('alp', {
|
||||
pageSize: search.pageSize.value,
|
||||
pageNumber: 0,
|
||||
restrictSearchableAttributes: ['name', 'description']
|
||||
})
|
||||
expect(search.searchResults.value).toEqual([pack('alpha')])
|
||||
expect(search.suggestions.value).toEqual([{ query: 'alpha' }])
|
||||
expect(search.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('uses node-search attributes when search mode is nodes', async () => {
|
||||
const search = useRegistrySearch({ initialSearchMode: 'nodes' })
|
||||
await flushSearch()
|
||||
|
||||
expect(mockSearchGateway.searchPacks).toHaveBeenCalledWith('', {
|
||||
pageSize: search.pageSize.value,
|
||||
pageNumber: 0,
|
||||
restrictSearchableAttributes: ['comfy_nodes']
|
||||
})
|
||||
})
|
||||
|
||||
it('sorts manually when a non-default sort field is selected', async () => {
|
||||
mockSearchGateway.getSortableFields.mockReturnValue([
|
||||
{ id: 'name', label: 'Name', direction: 'asc' }
|
||||
])
|
||||
mockSearchGateway.searchPacks.mockResolvedValueOnce({
|
||||
nodePacks: [pack('zeta'), pack('alpha')],
|
||||
querySuggestions: []
|
||||
})
|
||||
|
||||
const search = useRegistrySearch({ initialSortField: 'name' })
|
||||
await flushSearch()
|
||||
|
||||
expect(search.searchResults.value.map((item) => item.name)).toEqual([
|
||||
'alpha',
|
||||
'zeta'
|
||||
])
|
||||
})
|
||||
|
||||
it('appends results when loading later pages', async () => {
|
||||
mockSearchGateway.searchPacks
|
||||
.mockResolvedValueOnce({
|
||||
nodePacks: [pack('first')],
|
||||
querySuggestions: []
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
nodePacks: [pack('second')],
|
||||
querySuggestions: []
|
||||
})
|
||||
|
||||
const search = useRegistrySearch()
|
||||
await flushSearch()
|
||||
|
||||
search.pageNumber.value = 1
|
||||
await flushSearch()
|
||||
|
||||
expect(search.searchResults.value.map((item) => item.name)).toEqual([
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
expect(mockSearchGateway.searchPacks).toHaveBeenLastCalledWith('', {
|
||||
pageSize: search.pageSize.value,
|
||||
pageNumber: 1,
|
||||
restrictSearchableAttributes: ['name', 'description']
|
||||
})
|
||||
})
|
||||
|
||||
it('resets to the first page when sort field changes', async () => {
|
||||
mockSearchGateway.searchPacks
|
||||
.mockResolvedValueOnce({
|
||||
nodePacks: [pack('first')],
|
||||
querySuggestions: []
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
nodePacks: [pack('resorted')],
|
||||
querySuggestions: []
|
||||
})
|
||||
|
||||
const search = useRegistrySearch({ initialPageNumber: 2 })
|
||||
await flushSearch()
|
||||
|
||||
search.sortField.value = 'name'
|
||||
await flushSearch()
|
||||
|
||||
expect(search.pageNumber.value).toBe(0)
|
||||
expect(search.searchResults.value).toEqual([pack('resorted')])
|
||||
})
|
||||
|
||||
it('exposes sort options and clear cache from the gateway', () => {
|
||||
const fields: SortableField[] = [
|
||||
{ id: 'name', label: 'Name', direction: 'asc' }
|
||||
]
|
||||
mockSearchGateway.getSortableFields.mockReturnValue(fields)
|
||||
|
||||
const search = useRegistrySearch()
|
||||
search.clearCache()
|
||||
|
||||
expect(search.sortOptions.value).toBe(fields)
|
||||
expect(mockSearchGateway.clearSearchCache).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,281 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
|
||||
|
||||
const mockClient = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn()
|
||||
}))
|
||||
|
||||
const mockAxios = vi.hoisted(() => ({
|
||||
create: vi.fn(() => mockClient),
|
||||
isAxiosError: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`),
|
||||
clientId: 'client-1' as string | null,
|
||||
initialClientId: 'initial-client'
|
||||
}))
|
||||
|
||||
const mockManagerState = vi.hoisted(() => ({
|
||||
isNewManagerUI: { value: true }
|
||||
}))
|
||||
|
||||
const mockIsAbortError = vi.hoisted(() => vi.fn(() => false))
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: mockAxios
|
||||
}))
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'generated-ui-id'
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: mockApi
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/typeGuardUtil', () => ({
|
||||
isAbortError: mockIsAbortError
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => mockManagerState
|
||||
}))
|
||||
|
||||
function axiosError(status: number, message?: string): unknown {
|
||||
return {
|
||||
response: {
|
||||
status,
|
||||
data: message ? { message } : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('useComfyManagerService', () => {
|
||||
beforeEach(() => {
|
||||
mockClient.get.mockReset()
|
||||
mockClient.post.mockReset()
|
||||
mockAxios.isAxiosError.mockReset()
|
||||
mockAxios.isAxiosError.mockReturnValue(false)
|
||||
mockApi.apiURL.mockClear()
|
||||
mockManagerState.isNewManagerUI.value = true
|
||||
mockApi.clientId = 'client-1'
|
||||
mockApi.initialClientId = 'initial-client'
|
||||
mockIsAbortError.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('creates the manager API client with the v2 base URL', () => {
|
||||
expect(mockAxios.create).toHaveBeenCalledWith({
|
||||
baseURL: 'http://localhost:8188/v2/',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks requests when the new manager UI is unavailable', async () => {
|
||||
mockManagerState.isNewManagerUI.value = false
|
||||
const service = useComfyManagerService()
|
||||
|
||||
const result = await service.listInstalledPacks()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(service.error.value).toBe(
|
||||
'Manager service is not available in current mode'
|
||||
)
|
||||
expect(mockClient.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches installed packs and tracks loading state', async () => {
|
||||
mockClient.get.mockResolvedValueOnce({ data: { packs: [] } })
|
||||
const service = useComfyManagerService()
|
||||
|
||||
const promise = service.listInstalledPacks()
|
||||
expect(service.isLoading.value).toBe(true)
|
||||
await expect(promise).resolves.toEqual({ packs: [] })
|
||||
expect(service.isLoading.value).toBe(false)
|
||||
expect(service.error.value).toBeNull()
|
||||
expect(mockClient.get).toHaveBeenCalledWith('customnode/installed', {
|
||||
signal: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('passes queue status query params only when a client id is provided', async () => {
|
||||
mockClient.get
|
||||
.mockResolvedValueOnce({ data: { running: true } })
|
||||
.mockResolvedValueOnce({ data: { running: false } })
|
||||
const service = useComfyManagerService()
|
||||
|
||||
await service.getQueueStatus('client-a')
|
||||
await service.getQueueStatus()
|
||||
|
||||
expect(mockClient.get).toHaveBeenNthCalledWith(1, 'manager/queue/status', {
|
||||
params: { client_id: 'client-a' },
|
||||
signal: undefined
|
||||
})
|
||||
expect(mockClient.get).toHaveBeenNthCalledWith(2, 'manager/queue/status', {
|
||||
params: undefined,
|
||||
signal: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty bulk import result without making a request when no ids or urls are provided', async () => {
|
||||
const service = useComfyManagerService()
|
||||
|
||||
await expect(service.getImportFailInfoBulk()).resolves.toEqual({})
|
||||
|
||||
expect(mockClient.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('posts bulk import failure requests when ids are provided', async () => {
|
||||
mockClient.post.mockResolvedValueOnce({ data: { failed: [] } })
|
||||
const service = useComfyManagerService()
|
||||
const params = { cnr_ids: ['pack'] } as Parameters<
|
||||
typeof service.getImportFailInfoBulk
|
||||
>[0]
|
||||
|
||||
await expect(service.getImportFailInfoBulk(params)).resolves.toEqual({
|
||||
failed: []
|
||||
})
|
||||
expect(mockClient.post).toHaveBeenCalledWith(
|
||||
'customnode/import_fail_info_bulk',
|
||||
params,
|
||||
{ signal: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('queues install tasks with generated ids and starts the queue', async () => {
|
||||
mockClient.post
|
||||
.mockResolvedValueOnce({ data: null })
|
||||
.mockResolvedValueOnce({ data: null })
|
||||
const service = useComfyManagerService()
|
||||
const params = { id: 'pack-id' } as Parameters<
|
||||
typeof service.installPack
|
||||
>[0]
|
||||
|
||||
await expect(service.installPack(params)).resolves.toBeNull()
|
||||
|
||||
expect(mockClient.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'manager/queue/task',
|
||||
{
|
||||
kind: 'install',
|
||||
params,
|
||||
ui_id: 'generated-ui-id',
|
||||
client_id: 'client-1'
|
||||
},
|
||||
{ signal: undefined }
|
||||
)
|
||||
expect(mockClient.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'manager/queue/start',
|
||||
null,
|
||||
{ signal: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('uses initial client id when queueing and the current client id is absent', async () => {
|
||||
mockApi.clientId = null
|
||||
mockClient.post
|
||||
.mockResolvedValueOnce({ data: null })
|
||||
.mockResolvedValueOnce({ data: null })
|
||||
const service = useComfyManagerService()
|
||||
const params: Parameters<typeof service.updatePack>[0] = {
|
||||
node_name: 'pack-id'
|
||||
}
|
||||
|
||||
await service.updatePack(params, 'ui-id')
|
||||
|
||||
expect(mockClient.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'manager/queue/task',
|
||||
expect.objectContaining({
|
||||
kind: 'update',
|
||||
ui_id: 'ui-id',
|
||||
client_id: 'initial-client'
|
||||
}),
|
||||
{ signal: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('posts update all requests with query params and starts the queue', async () => {
|
||||
mockClient.post
|
||||
.mockResolvedValueOnce({ data: null })
|
||||
.mockResolvedValueOnce({ data: null })
|
||||
const service = useComfyManagerService()
|
||||
|
||||
await service.updateAllPacks({ mode: 'remote' }, 'ui-id')
|
||||
|
||||
expect(mockClient.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'manager/queue/update_all',
|
||||
null,
|
||||
{
|
||||
params: {
|
||||
mode: 'remote',
|
||||
client_id: 'client-1',
|
||||
ui_id: 'ui-id'
|
||||
},
|
||||
signal: undefined
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('maps route-specific axios errors', async () => {
|
||||
mockAxios.isAxiosError.mockReturnValueOnce(true)
|
||||
mockClient.post.mockRejectedValueOnce(axiosError(403))
|
||||
const service = useComfyManagerService()
|
||||
const params = { id: 'pack-id' } as Parameters<
|
||||
typeof service.installPack
|
||||
>[0]
|
||||
|
||||
await expect(service.installPack(params)).resolves.toBeNull()
|
||||
|
||||
expect(service.error.value).toBe(
|
||||
'Forbidden: A security error has occurred. Please check the terminal logs'
|
||||
)
|
||||
})
|
||||
|
||||
it('maps manager connectivity axios errors', async () => {
|
||||
mockAxios.isAxiosError.mockReturnValueOnce(true)
|
||||
mockClient.get.mockRejectedValueOnce(axiosError(404))
|
||||
const service = useComfyManagerService()
|
||||
|
||||
await service.listInstalledPacks()
|
||||
|
||||
expect(service.error.value).toBe('Could not connect to ComfyUI-Manager')
|
||||
})
|
||||
|
||||
it('uses response messages from generic axios errors', async () => {
|
||||
mockAxios.isAxiosError.mockReturnValueOnce(true)
|
||||
mockClient.get.mockRejectedValueOnce(axiosError(500, 'server exploded'))
|
||||
const service = useComfyManagerService()
|
||||
|
||||
await service.getTaskHistory()
|
||||
|
||||
expect(service.error.value).toBe('server exploded')
|
||||
})
|
||||
|
||||
it('uses thrown error messages for non-axios errors', async () => {
|
||||
mockClient.get.mockRejectedValueOnce(new Error('network down'))
|
||||
const service = useComfyManagerService()
|
||||
|
||||
await service.getImportFailInfo()
|
||||
|
||||
expect(service.error.value).toBe(
|
||||
'Fetching import failure information failed: network down'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not set an error for aborted requests', async () => {
|
||||
mockIsAbortError.mockReturnValueOnce(true)
|
||||
mockClient.get.mockRejectedValueOnce(new Error('aborted'))
|
||||
const service = useComfyManagerService()
|
||||
|
||||
await service.isLegacyManagerUI()
|
||||
|
||||
expect(service.error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,26 @@ type ManagerChannel = ManagerComponents['schemas']['ManagerChannel']
|
||||
type ManagerDatabaseSource =
|
||||
ManagerComponents['schemas']['ManagerDatabaseSource']
|
||||
type ManagerPackInstalled = ManagerComponents['schemas']['ManagerPackInstalled']
|
||||
type TaskHistoryItem = ManagerComponents['schemas']['TaskHistoryItem']
|
||||
|
||||
const { mockAppApi, mockClientId } = vi.hoisted(() => ({
|
||||
mockAppApi: new EventTarget(),
|
||||
mockClientId: { value: 'client-id' }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
api: mockAppApi
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
get clientId() {
|
||||
return mockClientId.value
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
|
||||
useComfyManagerService: vi.fn()
|
||||
@@ -44,17 +64,6 @@ vi.mock('@/composables/useServerLogs', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: vi.fn((key) => key)
|
||||
}),
|
||||
createI18n: vi.fn(() => ({
|
||||
global: {
|
||||
t: vi.fn((key) => key)
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
interface EnabledDisabledTestCase {
|
||||
desc: string
|
||||
installed: Record<string, ManagerPackInstalled>
|
||||
@@ -79,6 +88,7 @@ describe('useComfyManagerStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
mockClientId.value = 'client-id'
|
||||
mockManagerService = {
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
@@ -501,4 +511,229 @@ describe('useComfyManagerStore', () => {
|
||||
expect(store.isPackInstalled('disabled-pack')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('task state', () => {
|
||||
it('cleans installing state when manager reports task completion', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
await store.installPack.call({
|
||||
id: 'event-pack',
|
||||
repository: 'https://github.com/test/event-pack',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: 'latest',
|
||||
version: 'latest'
|
||||
})
|
||||
const taskId = vi.mocked(mockManagerService.installPack).mock.calls[0][1]
|
||||
|
||||
mockAppApi.dispatchEvent(
|
||||
new CustomEvent('cm-task-completed', { detail: {} })
|
||||
)
|
||||
mockAppApi.dispatchEvent(
|
||||
new CustomEvent('cm-task-completed', {
|
||||
detail: { ui_id: 'unknown-task' }
|
||||
})
|
||||
)
|
||||
expect(store.isPackInstalling('event-pack')).toBe(true)
|
||||
|
||||
mockAppApi.dispatchEvent(
|
||||
new CustomEvent('cm-task-completed', { detail: { ui_id: taskId } })
|
||||
)
|
||||
|
||||
expect(store.isPackInstalling('event-pack')).toBe(false)
|
||||
})
|
||||
|
||||
it('partitions task ids and logs by task status', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
store.taskLogs = [
|
||||
{ taskName: 'Success', taskId: 'success', logs: [] },
|
||||
{ taskName: 'Failed', taskId: 'failed', logs: [] },
|
||||
{ taskName: 'Unknown', taskId: 'unknown', logs: [] }
|
||||
]
|
||||
store.taskHistory = {
|
||||
success: {
|
||||
ui_id: 'success',
|
||||
status: { status_str: 'success' }
|
||||
} as TaskHistoryItem,
|
||||
failed: {
|
||||
ui_id: 'failed',
|
||||
status: { status_str: 'error' }
|
||||
} as TaskHistoryItem,
|
||||
unknown: {
|
||||
ui_id: 'unknown'
|
||||
} as TaskHistoryItem
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(store.succeededTasksIds).toEqual(['success'])
|
||||
expect(store.failedTasksIds).toEqual(['failed', 'unknown'])
|
||||
expect(store.succeededTasksLogs.map((log) => log.taskId)).toEqual([
|
||||
'success'
|
||||
])
|
||||
expect(store.failedTasksLogs.map((log) => log.taskId)).toEqual([
|
||||
'failed',
|
||||
'unknown'
|
||||
])
|
||||
})
|
||||
|
||||
it('records client-side task errors with fallback client ids and messages', async () => {
|
||||
mockClientId.value = ''
|
||||
vi.mocked(mockManagerService.installPack).mockRejectedValueOnce(
|
||||
new Error('install failed')
|
||||
)
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
await store.installPack.call({
|
||||
id: 'error-pack',
|
||||
repository: 'https://github.com/test/error-pack',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: 'latest',
|
||||
version: 'latest'
|
||||
})
|
||||
const taskId = vi.mocked(mockManagerService.installPack).mock.calls[0][1]
|
||||
if (!taskId) throw new Error('expected install task id to be generated')
|
||||
|
||||
expect(store.taskHistory[taskId].client_id).toBe('unknown')
|
||||
expect(store.taskHistory[taskId].status?.messages).toEqual([
|
||||
'install failed'
|
||||
])
|
||||
expect(store.isProcessingTasks).toBe(false)
|
||||
})
|
||||
|
||||
it('records string task errors', async () => {
|
||||
vi.mocked(mockManagerService.installPack).mockRejectedValueOnce(
|
||||
'install failed'
|
||||
)
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
await store.installPack.call({
|
||||
id: 'string-error-pack',
|
||||
repository: 'https://github.com/test/string-error-pack',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: 'latest',
|
||||
version: 'latest'
|
||||
})
|
||||
const taskId = vi.mocked(mockManagerService.installPack).mock.calls[0][1]
|
||||
if (!taskId) throw new Error('expected install task id to be generated')
|
||||
|
||||
expect(store.taskHistory[taskId].status?.messages).toEqual([
|
||||
'install failed'
|
||||
])
|
||||
})
|
||||
|
||||
it('resets task state and clears logs', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
await store.installPack.call({
|
||||
id: 'reset-pack',
|
||||
repository: 'https://github.com/test/reset-pack',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: 'latest',
|
||||
version: 'latest'
|
||||
})
|
||||
store.clearLogs()
|
||||
store.taskHistory = {
|
||||
success: {
|
||||
ui_id: 'success',
|
||||
status: { status_str: 'success' }
|
||||
} as TaskHistoryItem
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
store.resetTaskState()
|
||||
|
||||
expect(store.taskLogs).toEqual([])
|
||||
expect(store.taskHistory).toEqual({})
|
||||
expect(store.succeededTasksIds).toEqual([])
|
||||
expect(store.isPackInstalling('reset-pack')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('ignores installed entries without pack ids', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
await triggerPacksChange(
|
||||
{
|
||||
nameless: {
|
||||
enabled: true,
|
||||
cnr_id: undefined,
|
||||
aux_id: undefined,
|
||||
ver: '1.0.0'
|
||||
}
|
||||
},
|
||||
store
|
||||
)
|
||||
|
||||
expect(store.installedPacksIds.size).toBe(0)
|
||||
expect(store.isPackInstalled('nameless')).toBe(false)
|
||||
})
|
||||
|
||||
it('marks the store fresh when refresh returns no packs', async () => {
|
||||
vi.mocked(mockManagerService.listInstalledPacks).mockResolvedValue(null)
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
await store.refreshInstalledList()
|
||||
|
||||
expect(store.installedPacks).toEqual({})
|
||||
})
|
||||
|
||||
it('ignores install requests without ids', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
await store.installPack.call({
|
||||
id: '',
|
||||
repository: 'https://github.com/test/missing-id',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: 'latest',
|
||||
version: 'latest'
|
||||
})
|
||||
|
||||
expect(mockManagerService.installPack).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles installed package install actions for version changes and enabling', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
await triggerPacksChange(
|
||||
{
|
||||
'change-pack': {
|
||||
enabled: true,
|
||||
cnr_id: 'change-pack',
|
||||
aux_id: undefined,
|
||||
ver: '1.0.0'
|
||||
},
|
||||
'enable-pack': {
|
||||
enabled: true,
|
||||
cnr_id: 'enable-pack',
|
||||
aux_id: undefined,
|
||||
ver: 'latest'
|
||||
}
|
||||
},
|
||||
store
|
||||
)
|
||||
|
||||
await store.installPack.call({
|
||||
id: 'change-pack',
|
||||
repository: 'https://github.com/test/change-pack',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: '2.0.0',
|
||||
version: '2.0.0'
|
||||
})
|
||||
await store.installPack.call({
|
||||
id: 'enable-pack',
|
||||
repository: 'https://github.com/test/enable-pack',
|
||||
channel: 'dev' as ManagerChannel,
|
||||
mode: 'cache' as ManagerDatabaseSource,
|
||||
selected_version: 'latest',
|
||||
version: 'latest'
|
||||
})
|
||||
|
||||
expect(mockManagerService.installPack).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -329,7 +329,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
|
||||
await enqueueTaskWithLogs(task, t('g.enabling', { id: params.id }))
|
||||
}
|
||||
|
||||
const getInstalledPackVersion = (packId: NodePackId) => {
|
||||
const getInstalledPackVersion = (packId: NodePackId): string | undefined => {
|
||||
const pack = installedPacks.value[packId]
|
||||
return pack?.ver
|
||||
}
|
||||
|
||||
32
src/workbench/utils/nodeHelpUtil.test.ts
Normal file
32
src/workbench/utils/nodeHelpUtil.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { extractCustomNodeName, getNodeHelpBaseUrl } from './nodeHelpUtil'
|
||||
|
||||
function nodeDef(name: string, python_module: string) {
|
||||
return { name, python_module }
|
||||
}
|
||||
|
||||
describe('nodeHelpUtil', () => {
|
||||
it('extracts normalized custom node package names', () => {
|
||||
expect(
|
||||
extractCustomNodeName('custom_nodes.ComfyUI-TestPack@1.2.3.nodes')
|
||||
).toBe('ComfyUI-TestPack')
|
||||
expect(extractCustomNodeName('nodes')).toBeNull()
|
||||
expect(extractCustomNodeName(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns base URLs for blueprint, custom, and core nodes', () => {
|
||||
expect(
|
||||
getNodeHelpBaseUrl(nodeDef('SubgraphBlueprint.Test', 'blueprint'))
|
||||
).toBe('')
|
||||
expect(
|
||||
getNodeHelpBaseUrl(nodeDef('CustomNode', 'custom_nodes.TestPack.nodes'))
|
||||
).toBe('/extensions/TestPack/docs/')
|
||||
expect(getNodeHelpBaseUrl(nodeDef('LoadImage', 'nodes'))).toBe(
|
||||
'/docs/LoadImage/'
|
||||
)
|
||||
expect(getNodeHelpBaseUrl(nodeDef('UnknownNode', 'custom_nodes'))).toBe(
|
||||
'/docs/UnknownNode/'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
|
||||
import { normalizePackId } from '@/utils/packUtils'
|
||||
|
||||
@@ -7,13 +6,15 @@ export function extractCustomNodeName(
|
||||
): string | null {
|
||||
const modules = pythonModule?.split('.') || []
|
||||
if (modules.length >= 2 && modules[0] === 'custom_nodes') {
|
||||
// Use normalizePackId to remove version suffix
|
||||
return normalizePackId(modules[1])
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getNodeHelpBaseUrl(node: ComfyNodeDefImpl): string {
|
||||
export function getNodeHelpBaseUrl(node: {
|
||||
name: string
|
||||
python_module: string
|
||||
}): string {
|
||||
const nodeSource = getNodeSource(node.python_module)
|
||||
if (nodeSource.type === NodeSourceType.Blueprint) {
|
||||
return ''
|
||||
|
||||
Reference in New Issue
Block a user