Compare commits

...

4 Commits

Author SHA1 Message Date
huang47
b7d5b9a0f5 test: replace double casts and vi.mock(vue-i18n) in manager test suite
- usePacksSelection: drop fromAny; store now correctly types
  getInstalledPackVersion as string | undefined
- usePackInstall: remove vi.mock('vue-i18n'); update thrown message
  assertion to match real i18n translation; use fromPartial<NodePack>
  in pack() helper; remove redundant undefined-getter test case
- useManagerDisplayPacks: use fromPartial<NodePack> in pack() helper
- useConflictDetection: replace all as Partial<T> as ReturnType<...>
  double casts with fromPartial<ReturnType<...>>; remove dead
  checkComfyUIVersionChange from mock
- comfyManagerStore: remove unnecessary vi.mock('vue-i18n')
- nodeHelpUtil: drop ComfyNodeDefImpl cast from nodeDef() helper;
  parameter type is now structurally compatible without cast
2026-07-03 09:30:29 -07:00
huang47
1ba3f7c91b fix: widen getInstalledPackVersion return type and narrow nodeHelpUtil param
- getInstalledPackVersion now explicitly returns string | undefined since
  the installed packs map may not contain the queried key at runtime
- usePackInstall switches from useI18n() to module-level t from @/i18n,
  consistent with other composables in the same package
- getNodeHelpBaseUrl narrows its parameter from ComfyNodeDefImpl to the
  structural minimum { name, python_module } so tests need no cast
2026-07-03 09:29:42 -07:00
huang47
1ca2c86df1 test: cover manager composables 2026-07-02 14:39:01 -07:00
huang47
4faf1de7be refactor: hoist and reuse manager test mocks
Prep for follow-up commit adding new coverage: promote inline vi.fn()
mocks to named vi.hoisted() references, dedupe repeated fixture
helpers, and make shouldShowConflictModal/app-api listener mocks
controllable so new tests can assert on and drive them directly.
2026-07-02 14:38:18 -07:00
17 changed files with 2712 additions and 94 deletions

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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(() => {

View File

@@ -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)
})
})

View File

@@ -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)
})
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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: [] })
})
})
})

View File

@@ -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'])
})
})

View File

@@ -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
)
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})
})

View File

@@ -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
}

View 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/'
)
})
})

View File

@@ -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 ''