mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-08 08:08:04 +00:00
Compare commits
1 Commits
shihchi/co
...
shihchi/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff09f65c93 |
@@ -1,141 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getFromWebmFile } from '@/scripts/metadata/ebml'
|
||||
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
|
||||
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
|
||||
import { getDataFromJSON } from '@/scripts/metadata/json'
|
||||
import { getMp3Metadata } from '@/scripts/metadata/mp3'
|
||||
import { getOggMetadata } from '@/scripts/metadata/ogg'
|
||||
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
||||
import { getSvgMetadata } from '@/scripts/metadata/svg'
|
||||
import {
|
||||
getAvifMetadata,
|
||||
getFlacMetadata,
|
||||
getLatentMetadata,
|
||||
getPngMetadata,
|
||||
getWebpMetadata
|
||||
} from '@/scripts/pnginfo'
|
||||
|
||||
vi.mock('@/scripts/metadata/ebml', () => ({ getFromWebmFile: vi.fn() }))
|
||||
vi.mock('@/scripts/metadata/gltf', () => ({ getGltfBinaryMetadata: vi.fn() }))
|
||||
vi.mock('@/scripts/metadata/isobmff', () => ({ getFromIsobmffFile: vi.fn() }))
|
||||
vi.mock('@/scripts/metadata/json', () => ({ getDataFromJSON: vi.fn() }))
|
||||
vi.mock('@/scripts/metadata/mp3', () => ({ getMp3Metadata: vi.fn() }))
|
||||
vi.mock('@/scripts/metadata/ogg', () => ({ getOggMetadata: vi.fn() }))
|
||||
vi.mock('@/scripts/metadata/svg', () => ({ getSvgMetadata: vi.fn() }))
|
||||
vi.mock('@/scripts/pnginfo', () => ({
|
||||
getAvifMetadata: vi.fn(),
|
||||
getFlacMetadata: vi.fn(),
|
||||
getLatentMetadata: vi.fn(),
|
||||
getPngMetadata: vi.fn(),
|
||||
getWebpMetadata: vi.fn()
|
||||
}))
|
||||
|
||||
function file(type: string, name = 'file') {
|
||||
return new File(['data'], name, { type })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('getWorkflowDataFromFile', () => {
|
||||
it('routes png/avif/mp3/ogg/webm to their parsers and returns the result', async () => {
|
||||
vi.mocked(getPngMetadata).mockResolvedValue({ a: 1 } as never)
|
||||
expect(await getWorkflowDataFromFile(file('image/png'))).toEqual({ a: 1 })
|
||||
expect(getPngMetadata).toHaveBeenCalled()
|
||||
|
||||
await getWorkflowDataFromFile(file('image/avif'))
|
||||
expect(getAvifMetadata).toHaveBeenCalled()
|
||||
|
||||
await getWorkflowDataFromFile(file('audio/mpeg'))
|
||||
expect(getMp3Metadata).toHaveBeenCalled()
|
||||
|
||||
await getWorkflowDataFromFile(file('audio/ogg'))
|
||||
expect(getOggMetadata).toHaveBeenCalled()
|
||||
|
||||
await getWorkflowDataFromFile(file('video/webm'))
|
||||
expect(getFromWebmFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('extracts workflow/prompt from webp, preferring lowercase keys', async () => {
|
||||
vi.mocked(getWebpMetadata).mockResolvedValue({
|
||||
workflow: 'wf',
|
||||
prompt: 'pr'
|
||||
} as never)
|
||||
expect(await getWorkflowDataFromFile(file('image/webp'))).toEqual({
|
||||
workflow: 'wf',
|
||||
prompt: 'pr'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to capitalized webp keys when lowercase are absent', async () => {
|
||||
vi.mocked(getWebpMetadata).mockResolvedValue({
|
||||
Workflow: 'WF',
|
||||
Prompt: 'PR'
|
||||
} as never)
|
||||
expect(await getWorkflowDataFromFile(file('image/webp'))).toEqual({
|
||||
workflow: 'WF',
|
||||
prompt: 'PR'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles both flac mime types and extracts workflow/prompt', async () => {
|
||||
vi.mocked(getFlacMetadata).mockResolvedValue({ workflow: 'w' } as never)
|
||||
expect(await getWorkflowDataFromFile(file('audio/flac'))).toEqual({
|
||||
workflow: 'w',
|
||||
prompt: undefined
|
||||
})
|
||||
expect(await getWorkflowDataFromFile(file('audio/x-flac'))).toEqual({
|
||||
workflow: 'w',
|
||||
prompt: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to capitalized flac keys when lowercase are absent', async () => {
|
||||
vi.mocked(getFlacMetadata).mockResolvedValue({
|
||||
Workflow: 'WF',
|
||||
Prompt: 'PR'
|
||||
} as never)
|
||||
expect(await getWorkflowDataFromFile(file('audio/flac'))).toEqual({
|
||||
workflow: 'WF',
|
||||
prompt: 'PR'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes isobmff by mime type and by file extension', async () => {
|
||||
await getWorkflowDataFromFile(file('video/mp4'))
|
||||
await getWorkflowDataFromFile(file('video/quicktime'))
|
||||
await getWorkflowDataFromFile(file('video/x-m4v'))
|
||||
await getWorkflowDataFromFile(file('', 'clip.mp4'))
|
||||
await getWorkflowDataFromFile(file('', 'clip.mov'))
|
||||
await getWorkflowDataFromFile(file('', 'clip.m4v'))
|
||||
expect(getFromIsobmffFile).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('routes svg and gltf by mime type or extension', async () => {
|
||||
await getWorkflowDataFromFile(file('image/svg+xml'))
|
||||
await getWorkflowDataFromFile(file('', 'icon.svg'))
|
||||
expect(getSvgMetadata).toHaveBeenCalledTimes(2)
|
||||
|
||||
await getWorkflowDataFromFile(file('model/gltf-binary'))
|
||||
await getWorkflowDataFromFile(file('', 'model.glb'))
|
||||
expect(getGltfBinaryMetadata).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('routes latent/safetensors and json by extension or mime type', async () => {
|
||||
await getWorkflowDataFromFile(file('', 'x.latent'))
|
||||
await getWorkflowDataFromFile(file('', 'x.safetensors'))
|
||||
expect(getLatentMetadata).toHaveBeenCalledTimes(2)
|
||||
|
||||
await getWorkflowDataFromFile(file('application/json'))
|
||||
await getWorkflowDataFromFile(file('', 'x.json'))
|
||||
expect(getDataFromJSON).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns undefined for an unrecognized file', async () => {
|
||||
expect(
|
||||
await getWorkflowDataFromFile(file('application/zip', 'a.zip'))
|
||||
).toBe(undefined)
|
||||
})
|
||||
})
|
||||
@@ -69,7 +69,9 @@ export class ComfyModelDef {
|
||||
this.path_index = pathIndex
|
||||
this.file_name = name
|
||||
this.normalized_file_name = name.replaceAll('\\', '/')
|
||||
this.simplified_file_name = this.normalized_file_name.split('/').pop() ?? ''
|
||||
this.simplified_file_name = this.normalized_file_name.slice(
|
||||
this.normalized_file_name.lastIndexOf('/') + 1
|
||||
)
|
||||
if (this.simplified_file_name.endsWith('.safetensors')) {
|
||||
this.simplified_file_name = this.simplified_file_name.slice(
|
||||
0,
|
||||
|
||||
256
src/stores/nodeBookmarkStore.test.ts
Normal file
256
src/stores/nodeBookmarkStore.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
const BOOKMARK_ID = 'Comfy.NodeLibrary.Bookmarks.V2'
|
||||
const CUSTOMIZATION_ID = 'Comfy.NodeLibrary.BookmarksCustomization'
|
||||
|
||||
const { settings, setSpy, nodeDefs } = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, unknown>,
|
||||
setSpy: vi.fn(),
|
||||
nodeDefs: {} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', async () => {
|
||||
const { reactive } = await import('vue')
|
||||
const reactiveSettings = reactive(settings)
|
||||
setSpy.mockImplementation(async (id: string, value: unknown) => {
|
||||
reactiveSettings[id] = value
|
||||
})
|
||||
return {
|
||||
useSettingStore: () => ({
|
||||
get: (id: string) => reactiveSettings[id],
|
||||
set: setSpy
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({ allNodeDefsByName: nodeDefs }),
|
||||
buildNodeDefTree: (defs: unknown[]) => ({ key: 'root', children: defs }),
|
||||
createDummyFolderNodeDef: (path: string) => ({
|
||||
isDummyFolder: true,
|
||||
nodePath: path,
|
||||
name: path
|
||||
})
|
||||
}))
|
||||
|
||||
type BookmarkNodeFixture = Pick<
|
||||
ComfyNodeDefImpl,
|
||||
'isDummyFolder' | 'nodePath' | 'category' | 'name'
|
||||
>
|
||||
|
||||
function folderNode(nodePath: string) {
|
||||
const node = {
|
||||
isDummyFolder: true,
|
||||
nodePath,
|
||||
category: nodePath.replace(/\/$/, ''),
|
||||
name: nodePath
|
||||
} satisfies BookmarkNodeFixture
|
||||
return node as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
function leafNode(name: string, nodePath = name) {
|
||||
const node = {
|
||||
isDummyFolder: false,
|
||||
name,
|
||||
nodePath,
|
||||
category: ''
|
||||
} satisfies BookmarkNodeFixture
|
||||
return node as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
for (const key of Object.keys(settings)) delete settings[key]
|
||||
for (const key of Object.keys(nodeDefs)) delete nodeDefs[key]
|
||||
settings[BOOKMARK_ID] = []
|
||||
settings[CUSTOMIZATION_ID] = {}
|
||||
setSpy.mockClear()
|
||||
})
|
||||
|
||||
describe('nodeBookmarkStore', () => {
|
||||
it('reports isBookmarked by either nodePath or top-level name', () => {
|
||||
settings[BOOKMARK_ID] = ['sampling/KSampler', 'LoadImage']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
expect(store.isBookmarked(leafNode('KSampler', 'sampling/KSampler'))).toBe(
|
||||
true
|
||||
)
|
||||
expect(store.isBookmarked(leafNode('LoadImage'))).toBe(true)
|
||||
expect(store.isBookmarked(leafNode('VAEDecode'))).toBe(false)
|
||||
})
|
||||
|
||||
it('adds a bookmark by appending to the current list', async () => {
|
||||
settings[BOOKMARK_ID] = ['A']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.addBookmark('B')
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['A', 'B'])
|
||||
})
|
||||
|
||||
it('toggles an un-bookmarked node by adding its name', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.toggleBookmark(leafNode('KSampler'))
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler'])
|
||||
})
|
||||
|
||||
it('toggles a bookmarked node by deleting both nodePath and name', async () => {
|
||||
settings[BOOKMARK_ID] = ['sampling/KSampler', 'KSampler']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.toggleBookmark(leafNode('KSampler', 'sampling/KSampler'))
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler'])
|
||||
expect(setSpy).toHaveBeenLastCalledWith(BOOKMARK_ID, [])
|
||||
expect(store.bookmarks).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a folder under a parent and at the root', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
const rootPath = await store.addNewBookmarkFolder(undefined, 'Favorites')
|
||||
expect(rootPath).toBe('Favorites/')
|
||||
|
||||
const childPath = await store.addNewBookmarkFolder(
|
||||
folderNode('Favorites/'),
|
||||
'Nested'
|
||||
)
|
||||
expect(childPath).toBe('Favorites/Nested/')
|
||||
})
|
||||
|
||||
it('parses each bookmark into its parent category, dropping unknown node defs', () => {
|
||||
nodeDefs['LoadImage'] = leafNode('LoadImage')
|
||||
nodeDefs['KSampler'] = leafNode('KSampler')
|
||||
nodeDefs['Canny'] = leafNode('Canny')
|
||||
settings[BOOKMARK_ID] = [
|
||||
'LoadImage',
|
||||
'sampling/KSampler',
|
||||
'image/preprocessors/Canny',
|
||||
'sampling/Unknown',
|
||||
'Folder/'
|
||||
]
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
const children = (
|
||||
store.bookmarkedRoot as unknown as { children: BookmarkNodeFixture[] }
|
||||
).children
|
||||
|
||||
expect(
|
||||
children.map((node) =>
|
||||
node.isDummyFolder ? node.nodePath : [node.name, node.category]
|
||||
)
|
||||
).toEqual([
|
||||
['LoadImage', ''],
|
||||
['KSampler', 'sampling'],
|
||||
['Canny', 'image/preprocessors'],
|
||||
'Folder/'
|
||||
])
|
||||
})
|
||||
|
||||
describe('renameBookmarkFolder', () => {
|
||||
it('rejects renaming a non-folder node', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
await expect(
|
||||
store.renameBookmarkFolder(leafNode('KSampler'), 'New')
|
||||
).rejects.toThrow('Cannot rename non-folder node')
|
||||
})
|
||||
|
||||
it('rejects a name containing a slash', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
await expect(
|
||||
store.renameBookmarkFolder(folderNode('Old/'), 'a/b')
|
||||
).rejects.toThrow('cannot contain')
|
||||
})
|
||||
|
||||
it('rejects a rename that collides with an existing folder', async () => {
|
||||
settings[BOOKMARK_ID] = ['Taken/']
|
||||
const store = useNodeBookmarkStore()
|
||||
await expect(
|
||||
store.renameBookmarkFolder(folderNode('Old/'), 'Taken')
|
||||
).rejects.toThrow('already exists')
|
||||
})
|
||||
|
||||
it('rewrites matching bookmark paths on a valid rename', async () => {
|
||||
settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Other/Node']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.renameBookmarkFolder(folderNode('Old/'), 'New')
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, [
|
||||
'New/',
|
||||
'New/KSampler',
|
||||
'Other/Node'
|
||||
])
|
||||
})
|
||||
|
||||
it('does nothing when the folder keeps the same path', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.renameBookmarkFolder(folderNode('Old/'), 'Old')
|
||||
|
||||
expect(setSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a folder and all its descendants', async () => {
|
||||
settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Keep/Node']
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.deleteBookmarkFolder(folderNode('Old/'))
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['Keep/Node'])
|
||||
})
|
||||
|
||||
it('rejects deleting a non-folder node', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await expect(
|
||||
store.deleteBookmarkFolder(leafNode('KSampler'))
|
||||
).rejects.toThrow('Cannot delete non-folder node')
|
||||
})
|
||||
|
||||
describe('updateBookmarkCustomization', () => {
|
||||
it('persists a non-default customization', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.updateBookmarkCustomization('Folder/', {
|
||||
color: '#ff0000',
|
||||
icon: 'pi-star'
|
||||
})
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
|
||||
'Folder/': { color: '#ff0000', icon: 'pi-star' }
|
||||
})
|
||||
})
|
||||
|
||||
it('drops attributes set to their default values', async () => {
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.updateBookmarkCustomization('Folder/', {
|
||||
color: store.defaultBookmarkColor,
|
||||
icon: store.defaultBookmarkIcon
|
||||
})
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
|
||||
'Folder/': undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('renames a customization entry, moving the old key to the new one', async () => {
|
||||
settings[CUSTOMIZATION_ID] = { 'Old/': { color: '#abc' } }
|
||||
const store = useNodeBookmarkStore()
|
||||
|
||||
await store.renameBookmarkCustomization('Old/', 'New/')
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
|
||||
'New/': { color: '#abc' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -50,9 +50,9 @@ export const useNodeBookmarkStore = defineStore('nodeBookmark', () => {
|
||||
.map((bookmark: string) => {
|
||||
if (bookmark.endsWith('/')) return createDummyFolderNodeDef(bookmark)
|
||||
|
||||
const parts = bookmark.split('/')
|
||||
const name = parts.pop() ?? ''
|
||||
const category = parts.join('/')
|
||||
const slashIndex = bookmark.lastIndexOf('/')
|
||||
const name = bookmark.slice(slashIndex + 1)
|
||||
const category = bookmark.slice(0, Math.max(0, slashIndex))
|
||||
const srcNodeDef = nodeDefStore.allNodeDefsByName[name]
|
||||
if (!srcNodeDef) {
|
||||
return null
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
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('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
|
||||
|
||||
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 { id: 'pack-a', name: 'Pack A', ...over } as NodePack
|
||||
}
|
||||
|
||||
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(() => undefined as unknown as NodePack[]).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('manager.packInstall.nodeIdRequired')
|
||||
|
||||
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(() => {})
|
||||
try {
|
||||
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)
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,279 +0,0 @@
|
||||
import type * as VueUse from '@vueuse/core'
|
||||
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 {
|
||||
id,
|
||||
name: id,
|
||||
latest_version: latestVersion ? { version: latestVersion } : undefined
|
||||
} as NodePack
|
||||
}
|
||||
|
||||
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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user