mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 05:38:26 +00:00
Compare commits
1 Commits
codex/cove
...
shihchi/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c6a260f14 |
@@ -0,0 +1,93 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: {} }))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: () => {},
|
||||
getUserData: async () => ({ status: 404 }),
|
||||
storeUserData: async () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
|
||||
useWorkflowThumbnail: () => ({
|
||||
moveWorkflowThumbnail: () => {},
|
||||
clearThumbnail: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStoreV2', () => ({
|
||||
useWorkflowDraftStoreV2: () => ({
|
||||
getDraft: () => null,
|
||||
saveDraft: () => {},
|
||||
deleteDraft: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
interface WorkflowFlags {
|
||||
path: string
|
||||
isPersisted?: boolean
|
||||
isModified?: boolean
|
||||
}
|
||||
|
||||
function wf(flags: WorkflowFlags): ComfyWorkflow {
|
||||
return flags as unknown as ComfyWorkflow
|
||||
}
|
||||
|
||||
function paths(workflows: ComfyWorkflow[]) {
|
||||
return workflows.map((w) => w.path)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('workflowStore workflow lists', () => {
|
||||
it('persistedWorkflows excludes unpersisted and subgraph entries', () => {
|
||||
const store = useWorkflowStore()
|
||||
store.attachWorkflow(wf({ path: 'a.json', isPersisted: true }))
|
||||
store.attachWorkflow(wf({ path: 'b.json', isPersisted: false }))
|
||||
store.attachWorkflow(wf({ path: 'subgraphs/c.json', isPersisted: true }))
|
||||
|
||||
expect(paths(store.persistedWorkflows)).toEqual(['a.json'])
|
||||
})
|
||||
|
||||
it('modifiedWorkflows includes only modified workflows', () => {
|
||||
const store = useWorkflowStore()
|
||||
store.attachWorkflow(wf({ path: 'a.json', isModified: true }))
|
||||
store.attachWorkflow(wf({ path: 'b.json', isModified: false }))
|
||||
|
||||
expect(paths(store.modifiedWorkflows)).toEqual(['a.json'])
|
||||
})
|
||||
|
||||
it('bookmarkedWorkflows is empty when nothing is bookmarked', () => {
|
||||
const store = useWorkflowStore()
|
||||
store.attachWorkflow(wf({ path: 'a.json' }))
|
||||
|
||||
expect(store.bookmarkedWorkflows).toEqual([])
|
||||
})
|
||||
|
||||
it('bookmarkedWorkflows includes only bookmarked workflows', async () => {
|
||||
const store = useWorkflowStore()
|
||||
store.attachWorkflow(wf({ path: 'a.json' }))
|
||||
store.attachWorkflow(wf({ path: 'b.json' }))
|
||||
await useWorkflowBookmarkStore().setBookmarked('a.json', true)
|
||||
|
||||
expect(paths(store.bookmarkedWorkflows)).toEqual(['a.json'])
|
||||
})
|
||||
|
||||
it('openedWorkflowIndexShift returns null when no workflow is active', () => {
|
||||
const store = useWorkflowStore()
|
||||
store.attachWorkflow(wf({ path: 'a.json' }), 0)
|
||||
|
||||
expect(store.openedWorkflowIndexShift(1)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { Subgraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: {} }))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: () => {},
|
||||
getUserData: async () => ({ status: 404 }),
|
||||
storeUserData: async () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
|
||||
useWorkflowThumbnail: () => ({
|
||||
moveWorkflowThumbnail: () => {},
|
||||
clearThumbnail: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStoreV2', () => ({
|
||||
useWorkflowDraftStoreV2: () => ({
|
||||
getDraft: () => null,
|
||||
saveDraft: () => {},
|
||||
deleteDraft: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
const SUBGRAPH_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('workflowStore node locator translation', () => {
|
||||
it('treats a node as a root-graph node when no subgraph is active', () => {
|
||||
const store = useWorkflowStore()
|
||||
expect(store.nodeIdToNodeLocatorId(toNodeId(5))).toBe('5')
|
||||
})
|
||||
|
||||
it('prefixes the locator with an explicit subgraph uuid', () => {
|
||||
const store = useWorkflowStore()
|
||||
const subgraph = { id: SUBGRAPH_UUID } as unknown as Subgraph
|
||||
|
||||
expect(store.nodeIdToNodeLocatorId(toNodeId(5), subgraph)).toBe(
|
||||
`${SUBGRAPH_UUID}:5`
|
||||
)
|
||||
})
|
||||
|
||||
it('derives a locator from a node based on whether its graph is a subgraph', () => {
|
||||
const store = useWorkflowStore()
|
||||
const rootNode = { id: toNodeId(7), graph: {} } as unknown as LGraphNode
|
||||
expect(store.nodeToNodeLocatorId(rootNode)).toBe('7')
|
||||
|
||||
const subgraphNode = {
|
||||
id: toNodeId(7),
|
||||
graph: { id: SUBGRAPH_UUID, isRootGraph: false }
|
||||
} as unknown as LGraphNode
|
||||
expect(store.nodeToNodeLocatorId(subgraphNode)).toBe(`${SUBGRAPH_UUID}:7`)
|
||||
})
|
||||
|
||||
it('extracts the local node id from a locator', () => {
|
||||
const store = useWorkflowStore()
|
||||
expect(
|
||||
store.nodeLocatorIdToNodeId(
|
||||
createNodeLocatorId(SUBGRAPH_UUID, toNodeId(5))
|
||||
)
|
||||
).toBe(toNodeId(5))
|
||||
expect(
|
||||
store.nodeLocatorIdToNodeId(createNodeLocatorId(null, toNodeId(9)))
|
||||
).toBe(toNodeId(9))
|
||||
})
|
||||
|
||||
it('round-trips a root node id through locator translation', () => {
|
||||
const store = useWorkflowStore()
|
||||
const locator = store.nodeIdToNodeLocatorId(toNodeId(42))
|
||||
expect(store.nodeLocatorIdToNodeId(locator)).toBe(toNodeId(42))
|
||||
})
|
||||
|
||||
it('maps a root locator to a single-segment execution id', () => {
|
||||
const store = useWorkflowStore()
|
||||
expect(
|
||||
store.nodeLocatorIdToNodeExecutionId(
|
||||
createNodeLocatorId(null, toNodeId(5))
|
||||
)
|
||||
).toBe('5')
|
||||
})
|
||||
})
|
||||
100
src/platform/workflow/management/stores/workflowTabs.test.ts
Normal file
100
src/platform/workflow/management/stores/workflowTabs.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: {} }))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: () => {},
|
||||
getUserData: async () => ({ status: 404 }),
|
||||
storeUserData: async () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
|
||||
useWorkflowThumbnail: () => ({
|
||||
moveWorkflowThumbnail: () => {},
|
||||
clearThumbnail: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStoreV2', () => ({
|
||||
useWorkflowDraftStoreV2: () => ({
|
||||
getDraft: () => null,
|
||||
saveDraft: () => {},
|
||||
deleteDraft: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
function wf(path: string): ComfyWorkflow {
|
||||
return { path } as unknown as ComfyWorkflow
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('workflowStore tab management', () => {
|
||||
it('attaches workflows into the lookup and finds them by path', () => {
|
||||
const store = useWorkflowStore()
|
||||
const a = wf('a.json')
|
||||
store.attachWorkflow(a)
|
||||
|
||||
// Pinia wraps stored objects in reactive proxies, so compare structurally.
|
||||
expect(store.getWorkflowByPath('a.json')).toEqual(a)
|
||||
expect(store.getWorkflowByPath('missing.json')).toBeNull()
|
||||
expect(store.workflows).toContainEqual(a)
|
||||
})
|
||||
|
||||
it('tracks which workflows are open', () => {
|
||||
const store = useWorkflowStore()
|
||||
const open = wf('open.json')
|
||||
const closed = wf('closed.json')
|
||||
store.attachWorkflow(open, 0)
|
||||
store.attachWorkflow(closed)
|
||||
|
||||
expect(store.isOpen(open)).toBe(true)
|
||||
expect(store.isOpen(closed)).toBe(false)
|
||||
expect(store.openWorkflows).toEqual([open])
|
||||
})
|
||||
|
||||
it('reorders open workflow tabs', () => {
|
||||
const store = useWorkflowStore()
|
||||
const a = wf('a.json')
|
||||
const b = wf('b.json')
|
||||
const c = wf('c.json')
|
||||
store.attachWorkflow(a, 0)
|
||||
store.attachWorkflow(b, 1)
|
||||
store.attachWorkflow(c, 2)
|
||||
|
||||
store.reorderWorkflows(0, 2)
|
||||
|
||||
expect(store.openWorkflows).toEqual([b, c, a])
|
||||
})
|
||||
|
||||
it('opens background workflows on the requested side, ignoring unknown paths', () => {
|
||||
const store = useWorkflowStore()
|
||||
const left = wf('left.json')
|
||||
const mid = wf('mid.json')
|
||||
const right = wf('right.json')
|
||||
store.attachWorkflow(left)
|
||||
store.attachWorkflow(mid, 0)
|
||||
store.attachWorkflow(right)
|
||||
|
||||
store.openWorkflowsInBackground({
|
||||
left: ['left.json', 'unknown.json'],
|
||||
right: ['right.json']
|
||||
})
|
||||
|
||||
expect(store.openWorkflows).toEqual([left, mid, right])
|
||||
expect(store.activeWorkflow).toBeNull()
|
||||
})
|
||||
|
||||
it('reports no active workflow before one is opened', () => {
|
||||
const store = useWorkflowStore()
|
||||
expect(store.isActive(wf('a.json'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
|
||||
const { coreByLocale, coreResult, customResult, dist, locale } = vi.hoisted(
|
||||
() => ({
|
||||
coreByLocale: { value: {} as Record<string, unknown[]> },
|
||||
coreResult: { value: [] as unknown[] },
|
||||
customResult: { value: {} as Record<string, string[]> },
|
||||
dist: { isCloud: false },
|
||||
locale: { value: 'en' }
|
||||
})
|
||||
)
|
||||
|
||||
const baseTemplate = {
|
||||
name: 'default',
|
||||
title: 'Default',
|
||||
description: 'A basic template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp'
|
||||
}
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getWorkflowTemplates: async () => customResult.value,
|
||||
getCoreWorkflowTemplates: async (locale: string) =>
|
||||
coreByLocale.value[locale] ?? coreResult.value,
|
||||
fileURL: (p: string) => p
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
i18n: { global: { locale } },
|
||||
st: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return dist.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
function coreCategory(
|
||||
overrides: Partial<WorkflowTemplates> = {}
|
||||
): WorkflowTemplates {
|
||||
return {
|
||||
moduleName: 'default',
|
||||
title: 'Basics',
|
||||
type: 'image',
|
||||
templates: [baseTemplate],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function navItems(items: (NavItemData | NavGroupData)[]) {
|
||||
return items.flatMap((item) => ('items' in item ? item.items : [item]))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
coreByLocale.value = {}
|
||||
coreResult.value = [coreCategory()]
|
||||
customResult.value = {}
|
||||
dist.isCloud = false
|
||||
locale.value = 'en'
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () => new Response('', { headers: { 'content-type': 'text/html' } })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
describe('workflowTemplatesStore', () => {
|
||||
it('loads core templates and indexes their names', async () => {
|
||||
const store = useWorkflowTemplatesStore()
|
||||
expect(store.isLoaded).toBe(false)
|
||||
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
expect(store.isLoaded).toBe(true)
|
||||
expect(store.knownTemplateNames.has('default')).toBe(true)
|
||||
expect(store.getTemplateByName('default')?.name).toBe('default')
|
||||
expect(store.getTemplateByName('missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes grouped templates with localized titles', async () => {
|
||||
const store = useWorkflowTemplatesStore()
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
expect(store.groupedTemplates.length).toBeGreaterThan(0)
|
||||
|
||||
const exampleGroup = store.groupedTemplates[0]
|
||||
expect(exampleGroup.label).toBe('ComfyUI Examples')
|
||||
const moduleTitles = exampleGroup.modules.map((m) => m.localizedTitle)
|
||||
expect(moduleTitles).toContain('All Templates')
|
||||
expect(moduleTitles).toContain('Basics')
|
||||
|
||||
const allNames = store.groupedTemplates.flatMap((g) =>
|
||||
(g.modules ?? []).flatMap((m) => (m.templates ?? []).map((t) => t.name))
|
||||
)
|
||||
expect(allNames).toContain('default')
|
||||
})
|
||||
|
||||
it('filters nav categories from loaded template metadata', async () => {
|
||||
coreResult.value = [
|
||||
coreCategory({
|
||||
title: 'Getting Started',
|
||||
isEssential: true,
|
||||
templates: [{ ...baseTemplate, name: 'starter', title: 'Starter' }]
|
||||
}),
|
||||
coreCategory({
|
||||
title: 'Image Tools',
|
||||
category: 'GENERATION TYPE',
|
||||
templates: [
|
||||
{
|
||||
...baseTemplate,
|
||||
name: 'partner-upscale',
|
||||
title: 'Partner Upscale',
|
||||
openSource: false
|
||||
},
|
||||
{
|
||||
...baseTemplate,
|
||||
name: 'local-only',
|
||||
requiresCustomNodes: ['custom-node']
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
customResult.value = { CustomPack: ['custom-flow'] }
|
||||
const store = useWorkflowTemplatesStore()
|
||||
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
const allItems = navItems(store.navGroupedTemplates)
|
||||
const basicsId = allItems.find(
|
||||
(item) => item.label === 'Getting Started'
|
||||
)?.id
|
||||
const categoryId = allItems.find((item) => item.label === 'Image Tools')?.id
|
||||
|
||||
expect(store.filterTemplatesByCategory('all').map((t) => t.name)).toEqual([
|
||||
'starter',
|
||||
'partner-upscale',
|
||||
'custom-flow'
|
||||
])
|
||||
expect(
|
||||
store.filterTemplatesByCategory('popular').map((t) => t.name)
|
||||
).toEqual(['starter', 'partner-upscale', 'custom-flow'])
|
||||
expect(
|
||||
store.filterTemplatesByCategory(basicsId ?? '').map((t) => t.name)
|
||||
).toEqual(['starter'])
|
||||
expect(
|
||||
store.filterTemplatesByCategory(categoryId ?? '').map((t) => t.name)
|
||||
).toEqual(['partner-upscale'])
|
||||
expect(
|
||||
store.filterTemplatesByCategory('partner-nodes').map((t) => t.name)
|
||||
).toEqual(['partner-upscale'])
|
||||
expect(
|
||||
store.filterTemplatesByCategory('extension-CustomPack').map((t) => t.name)
|
||||
).toEqual(['custom-flow'])
|
||||
expect(
|
||||
store.filterTemplatesByCategory('unknown').map((t) => t.name)
|
||||
).toEqual(['starter', 'partner-upscale', 'custom-flow'])
|
||||
})
|
||||
|
||||
it('loads logo indexes and rejects unsafe logo paths', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
valid: 'logos/valid.svg',
|
||||
missingExtension: 'logos/valid',
|
||||
parent: '../secret.svg',
|
||||
rooted: '/logos/rooted.svg'
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
const store = useWorkflowTemplatesStore()
|
||||
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
expect(store.getLogoUrl('valid')).toBe('/templates/logos/valid.svg')
|
||||
expect(store.getLogoUrl('missing')).toBe('')
|
||||
expect(store.getLogoUrl('missingExtension')).toBe('')
|
||||
expect(store.getLogoUrl('parent')).toBe('')
|
||||
expect(store.getLogoUrl('rooted')).toBe('')
|
||||
})
|
||||
|
||||
it('returns english metadata when cloud loads a non-english locale', async () => {
|
||||
dist.isCloud = true
|
||||
locale.value = 'fr'
|
||||
coreByLocale.value = {
|
||||
fr: [
|
||||
coreCategory({
|
||||
templates: [{ ...baseTemplate, name: 'localized', title: 'Localise' }]
|
||||
})
|
||||
],
|
||||
en: [
|
||||
coreCategory({
|
||||
title: 'English Category',
|
||||
templates: [
|
||||
{
|
||||
...baseTemplate,
|
||||
name: 'localized',
|
||||
tags: ['tag'],
|
||||
useCase: 'test',
|
||||
models: ['model'],
|
||||
license: 'MIT'
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
}
|
||||
const store = useWorkflowTemplatesStore()
|
||||
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
expect(store.getEnglishMetadata('localized')).toEqual({
|
||||
tags: ['tag'],
|
||||
category: 'English Category',
|
||||
useCase: 'test',
|
||||
models: ['model'],
|
||||
license: 'MIT'
|
||||
})
|
||||
expect(store.getEnglishMetadata('missing')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not refetch once loaded', async () => {
|
||||
const store = useWorkflowTemplatesStore()
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
coreResult.value = []
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
expect(store.knownTemplateNames.has('default')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns null english metadata when no english templates are loaded', async () => {
|
||||
const store = useWorkflowTemplatesStore()
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
expect(store.getEnglishMetadata('default')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import type * as VueRouter from 'vue-router'
|
||||
|
||||
@@ -102,12 +103,24 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
function makeSubgraph(id: string): Subgraph {
|
||||
return fromPartial<Subgraph>({
|
||||
id,
|
||||
isRootGraph: false,
|
||||
rootGraph: app.rootGraph,
|
||||
_nodes: [],
|
||||
nodes: []
|
||||
})
|
||||
}
|
||||
|
||||
async function makeDuplicatedNavigationFailure(): Promise<Error> {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: {} }]
|
||||
})
|
||||
await router.push('/')
|
||||
const failure = await router.push('/')
|
||||
if (!failure) throw new Error('Expected duplicated navigation failure')
|
||||
return failure
|
||||
}
|
||||
|
||||
async function flushHashWatcher() {
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
@@ -118,6 +131,7 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(app.canvas.setGraph).mockReset()
|
||||
app.rootGraph.id = ids.root
|
||||
app.rootGraph.subgraphs.clear()
|
||||
app.canvas.subgraph = undefined
|
||||
@@ -230,6 +244,42 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not warn when recovery redirect hits a duplicated navigation', async () => {
|
||||
routerMocks.replace.mockRejectedValueOnce(
|
||||
await makeDuplicatedNavigationFailure()
|
||||
)
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalledWith(
|
||||
'[subgraphNavigation] router.replace rejected during recovery',
|
||||
expect.any(Error)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('recovers to root when canvas is unavailable during redirect cleanup', async () => {
|
||||
const appWithOptionalCanvas = app as unknown as {
|
||||
canvas: typeof app.canvas | undefined
|
||||
}
|
||||
const canvas = appWithOptionalCanvas.canvas
|
||||
appWithOptionalCanvas.canvas = undefined
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = '#not-a-valid-uuid'
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
|
||||
appWithOptionalCanvas.canvas = canvas
|
||||
})
|
||||
|
||||
it('redirects when a workflow load resolves but the subgraph is still missing', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
workflowStoreState.openWorkflows = [
|
||||
@@ -304,4 +354,196 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => {
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('updateHash does nothing on initial load with an empty hash', async () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(routerMocks.replace).not.toHaveBeenCalled()
|
||||
expect(routerMocks.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updateHash follows a non-empty initial subgraph hash', async () => {
|
||||
const subgraph = makeSubgraph(ids.validSubgraph)
|
||||
app.rootGraph.subgraphs.set(subgraph.id, subgraph)
|
||||
vi.mocked(app.canvas.setGraph).mockImplementation((graph) => {
|
||||
app.canvas.graph = graph
|
||||
})
|
||||
routeHashRef.value = `#${ids.validSubgraph}`
|
||||
const store = useSubgraphNavigationStore()
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(subgraph)
|
||||
})
|
||||
|
||||
it('updateHash does not treat the initial root hash as a subgraph', async () => {
|
||||
routeHashRef.value = `#${ids.root}`
|
||||
app.canvas.graph = app.rootGraph
|
||||
const store = useSubgraphNavigationStore()
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(workflowStoreState.activeSubgraph).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updateHash replaces an empty hash and pushes the active graph id', async () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
await store.updateHash()
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.validSubgraph })
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
expect(routerMocks.push).toHaveBeenCalledWith(`#${ids.validSubgraph}`)
|
||||
})
|
||||
|
||||
it('updateHash skips router push when hash already matches the active graph', async () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
await store.updateHash()
|
||||
routeHashRef.value = `#${ids.validSubgraph}`
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.validSubgraph })
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(routerMocks.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updateHash skips router push when the active graph has no id', async () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
await store.updateHash()
|
||||
routeHashRef.value = '#old'
|
||||
app.canvas.graph = fromPartial<LGraph>({})
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(routerMocks.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updateHash warns when router push rejects', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
routerMocks.push.mockRejectedValueOnce(new Error('push failed'))
|
||||
const store = useSubgraphNavigationStore()
|
||||
await store.updateHash()
|
||||
routeHashRef.value = '#old'
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.validSubgraph })
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[subgraphNavigation] router.push rejected',
|
||||
expect.any(Error)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('updateHash ignores duplicated router push failures', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
routerMocks.push.mockRejectedValueOnce(
|
||||
await makeDuplicatedNavigationFailure()
|
||||
)
|
||||
const store = useSubgraphNavigationStore()
|
||||
await store.updateHash()
|
||||
routeHashRef.value = `#${ids.root}`
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.validSubgraph })
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('skips workflows without active state during hash recovery', async () => {
|
||||
workflowStoreState.openWorkflows = [
|
||||
fromPartial<ComfyWorkflow>({ path: 'inactive.json' })
|
||||
]
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
})
|
||||
|
||||
it('skips workflow states and subgraphs that do not match the hash', async () => {
|
||||
workflowStoreState.openWorkflows = [
|
||||
fromPartial<ComfyWorkflow>({
|
||||
path: 'other-workflow.json',
|
||||
activeState: {
|
||||
id: ids.validSubgraph,
|
||||
definitions: {
|
||||
subgraphs: [{ id: ids.validSubgraph }]
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
})
|
||||
|
||||
it('handles workflow states with no subgraph definitions during recovery', async () => {
|
||||
workflowStoreState.openWorkflows = [
|
||||
fromPartial<ComfyWorkflow>({
|
||||
path: 'no-definitions.json',
|
||||
activeState: { id: ids.validSubgraph }
|
||||
})
|
||||
]
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a workflow and navigates to the loaded root graph', async () => {
|
||||
workflowStoreState.openWorkflows = [
|
||||
fromPartial<ComfyWorkflow>({
|
||||
path: 'root-workflow.json',
|
||||
activeState: {
|
||||
id: ids.deletedSubgraph,
|
||||
definitions: { subgraphs: [] }
|
||||
}
|
||||
})
|
||||
]
|
||||
workflowServiceMocks.openWorkflow.mockImplementation(async () => {
|
||||
app.rootGraph.id = ids.deletedSubgraph
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.root })
|
||||
})
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not reset the graph when loaded workflow is already active', async () => {
|
||||
workflowStoreState.openWorkflows = [
|
||||
fromPartial<ComfyWorkflow>({
|
||||
path: 'already-active.json',
|
||||
activeState: {
|
||||
id: ids.deletedSubgraph,
|
||||
definitions: { subgraphs: [] }
|
||||
}
|
||||
})
|
||||
]
|
||||
workflowServiceMocks.openWorkflow.mockImplementation(async () => {
|
||||
app.rootGraph.id = ids.deletedSubgraph
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.deletedSubgraph })
|
||||
})
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(workflowServiceMocks.openWorkflow).toHaveBeenCalled()
|
||||
)
|
||||
|
||||
expect(app.canvas.setGraph).not.toHaveBeenCalledWith(app.rootGraph)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 { nextTick } from 'vue'
|
||||
@@ -136,6 +137,20 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
})
|
||||
|
||||
describe('saveViewport', () => {
|
||||
it('does not save when canvas is unavailable', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const canvas = app.canvas
|
||||
const appWithOptionalCanvas = app as unknown as {
|
||||
canvas: typeof app.canvas | undefined
|
||||
}
|
||||
appWithOptionalCanvas.canvas = undefined
|
||||
|
||||
store.saveViewport('root')
|
||||
|
||||
expect(store.viewportCache.has(':root')).toBe(false)
|
||||
appWithOptionalCanvas.canvas = canvas
|
||||
})
|
||||
|
||||
it('saves viewport state for root graph', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
mockCanvas.ds.state.scale = 2
|
||||
@@ -164,6 +179,36 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
})
|
||||
|
||||
describe('restoreViewport', () => {
|
||||
it('does nothing when canvas is unavailable', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const canvas = app.canvas
|
||||
const appWithOptionalCanvas = app as unknown as {
|
||||
canvas: typeof app.canvas | undefined
|
||||
}
|
||||
appWithOptionalCanvas.canvas = undefined
|
||||
|
||||
store.restoreViewport('root')
|
||||
|
||||
expect(mockSetDirty).not.toHaveBeenCalled()
|
||||
expect(rafCallbacks).toHaveLength(0)
|
||||
appWithOptionalCanvas.canvas = canvas
|
||||
})
|
||||
|
||||
it('does not apply cached viewport when canvas disappears', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const canvas = app.canvas
|
||||
const appWithOptionalCanvas = app as unknown as {
|
||||
canvas: typeof app.canvas | undefined
|
||||
}
|
||||
store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] })
|
||||
appWithOptionalCanvas.canvas = undefined
|
||||
|
||||
store.restoreViewport('root')
|
||||
|
||||
expect(mockSetDirty).not.toHaveBeenCalled()
|
||||
appWithOptionalCanvas.canvas = canvas
|
||||
})
|
||||
|
||||
it('restores cached viewport', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] })
|
||||
@@ -266,7 +311,10 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
expect(mockFitView).toHaveBeenCalledOnce()
|
||||
|
||||
// User navigated away before the inner RAF fired
|
||||
mockCanvas.subgraph = { id: 'different-graph' } as never
|
||||
mockCanvas.subgraph = fromPartial<Subgraph>({
|
||||
id: 'different-graph',
|
||||
isRootGraph: false
|
||||
})
|
||||
rafCallbacks[1](performance.now())
|
||||
|
||||
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
|
||||
@@ -283,7 +331,10 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
|
||||
// Simulate graph switching away before rAF fires
|
||||
mockCanvas.subgraph = { id: 'different-graph' } as never
|
||||
mockCanvas.subgraph = fromPartial<Subgraph>({
|
||||
id: 'different-graph',
|
||||
isRootGraph: false
|
||||
})
|
||||
|
||||
rafCallbacks[0](performance.now())
|
||||
|
||||
@@ -341,6 +392,23 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
expect(mockCanvas.ds.offset).toEqual([100, 100])
|
||||
})
|
||||
|
||||
it('does not save the outgoing viewport while a workflow switch is blocked', async () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const subgraph = fromPartial<Subgraph>({
|
||||
id: 'sub1',
|
||||
isRootGraph: false,
|
||||
rootGraph: app.rootGraph
|
||||
})
|
||||
|
||||
store.saveCurrentViewport()
|
||||
store.viewportCache.clear()
|
||||
workflowStore.activeSubgraph = subgraph
|
||||
await nextTick()
|
||||
|
||||
expect(store.viewportCache.has(':root')).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves pre-existing cache entries across workflow switches', async () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
@@ -10,9 +10,14 @@ import {
|
||||
import type { ExportedSubgraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { GlobalSubgraphData } from '@/scripts/api'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
@@ -36,6 +41,7 @@ vi.mock('@/scripts/api', () => ({
|
||||
storeUserData: vi.fn(),
|
||||
listUserDataFullInfo: vi.fn(),
|
||||
getGlobalSubgraphs: vi.fn(),
|
||||
deleteUserData: vi.fn(),
|
||||
apiURL: vi.fn(),
|
||||
addEventListener: vi.fn()
|
||||
}
|
||||
@@ -98,6 +104,12 @@ describe('useSubgraphStore', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useSubgraphStore()
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
prompt: vi.fn(() => 'testname'),
|
||||
confirm: vi.fn(() => true)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow publishing of a subgraph', async () => {
|
||||
@@ -134,6 +146,86 @@ describe('useSubgraphStore', () => {
|
||||
await store.publishSubgraph()
|
||||
expect(api.storeUserData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects publishing when a single subgraph node is not selected', async () => {
|
||||
vi.mocked(comfyApp.canvas).selectedItems = new Set()
|
||||
|
||||
await expect(store.publishSubgraph()).rejects.toThrow(
|
||||
'Must have single SubgraphNode selected to publish'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects publishing when serialization produces multiple nodes', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
|
||||
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
|
||||
nodes: [subgraphNode.serialize(), subgraphNode.serialize()],
|
||||
subgraphs: []
|
||||
}))
|
||||
|
||||
await expect(store.publishSubgraph()).rejects.toThrow(
|
||||
'Must have single SubgraphNode selected to publish'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects publishing when the serialized node is not a subgraph node', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
|
||||
vi.mocked(comfyApp.canvas).draw = vi.fn()
|
||||
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
|
||||
nodes: [{ ...subgraphNode.serialize(), type: 'missing' }],
|
||||
subgraphs: [fromAny<ExportedSubgraph, unknown>(subgraph.serialize())]
|
||||
}))
|
||||
|
||||
await expect(store.publishSubgraph('invalid')).rejects.toThrow(
|
||||
'Loaded subgraph blueprint does not contain valid subgraph'
|
||||
)
|
||||
expect(api.storeUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not publish when the name prompt is cancelled', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
|
||||
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
|
||||
nodes: [subgraphNode.serialize()],
|
||||
subgraphs: [fromAny<ExportedSubgraph, unknown>(subgraph.serialize())]
|
||||
}))
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
prompt: vi.fn(() => null),
|
||||
confirm: vi.fn(() => true)
|
||||
})
|
||||
)
|
||||
|
||||
await store.publishSubgraph()
|
||||
|
||||
expect(api.storeUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not overwrite an existing blueprint when confirmation is cancelled', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
|
||||
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
|
||||
nodes: [subgraphNode.serialize()],
|
||||
subgraphs: [fromAny<ExportedSubgraph, unknown>(subgraph.serialize())]
|
||||
}))
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
prompt: vi.fn(() => 'test'),
|
||||
confirm: vi.fn(() => false)
|
||||
})
|
||||
)
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
|
||||
await store.publishSubgraph('test')
|
||||
|
||||
expect(api.storeUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should display published nodes in the node library', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
expect(
|
||||
@@ -148,6 +240,30 @@ describe('useSubgraphStore', () => {
|
||||
//check active graph
|
||||
expect(comfyApp.loadGraphData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switches into the nested subgraph when editing opens a wrapper graph', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
const setGraph = vi.fn()
|
||||
const nested = { id: 'nested' }
|
||||
vi.mocked(comfyApp.canvas).graph = fromAny<
|
||||
NonNullable<typeof comfyApp.canvas.graph>,
|
||||
unknown
|
||||
>({
|
||||
nodes: [{ subgraph: nested }],
|
||||
setGraph
|
||||
})
|
||||
vi.mocked(comfyApp.canvas).setGraph = setGraph
|
||||
|
||||
await store.editBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')
|
||||
|
||||
expect(setGraph).toHaveBeenCalledWith(nested)
|
||||
})
|
||||
|
||||
it('throws when editing an unloaded blueprint', async () => {
|
||||
await expect(
|
||||
store.editBlueprint(BLUEPRINT_TYPE_PREFIX + 'missing')
|
||||
).rejects.toThrow('not yet loaded')
|
||||
})
|
||||
it('should allow subgraphs to be added to graph', async () => {
|
||||
//mock
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
@@ -166,6 +282,12 @@ describe('useSubgraphStore', () => {
|
||||
expect(second.nodes[0].id).not.toBe(-1)
|
||||
expect(second.definitions!.subgraphs![0].id).toBe('123')
|
||||
})
|
||||
|
||||
it('throws when getting an unloaded blueprint', () => {
|
||||
expect(() => store.getBlueprint(BLUEPRINT_TYPE_PREFIX + 'missing')).toThrow(
|
||||
'not yet loaded'
|
||||
)
|
||||
})
|
||||
it('should identify user blueprints as non-global', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
expect(store.isGlobalBlueprint('test')).toBe(false)
|
||||
@@ -188,6 +310,59 @@ describe('useSubgraphStore', () => {
|
||||
expect(store.isGlobalBlueprint('nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
describe('deleteBlueprint', () => {
|
||||
it('throws for unloaded blueprints', async () => {
|
||||
await expect(
|
||||
store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'missing')
|
||||
).rejects.toThrow('not yet loaded')
|
||||
})
|
||||
|
||||
it('does not delete global blueprints', async () => {
|
||||
await mockFetch(
|
||||
{},
|
||||
{
|
||||
global_bp: {
|
||||
name: 'Global Blueprint',
|
||||
info: { node_pack: 'comfy_essentials' },
|
||||
data: JSON.stringify(mockGraph)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'global_bp')
|
||||
|
||||
expect(api.deleteUserData).not.toHaveBeenCalled()
|
||||
expect(store.isGlobalBlueprint('global_bp')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not delete when confirmation is cancelled', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
prompt: vi.fn(() => 'testname'),
|
||||
confirm: vi.fn(() => false)
|
||||
})
|
||||
)
|
||||
|
||||
await store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')
|
||||
|
||||
expect(api.deleteUserData).not.toHaveBeenCalled()
|
||||
expect(store.isUserBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')).toBe(true)
|
||||
})
|
||||
|
||||
it('deletes user blueprints after confirmation', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
vi.mocked(api.deleteUserData).mockResolvedValue({
|
||||
status: 204
|
||||
} as Response)
|
||||
|
||||
await store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')
|
||||
|
||||
expect(api.deleteUserData).toHaveBeenCalledWith('subgraphs/test.json')
|
||||
expect(store.isUserBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUserBlueprint', () => {
|
||||
it('should return true for user blueprints', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
@@ -285,6 +460,205 @@ describe('useSubgraphStore', () => {
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('continues when global blueprint discovery rejects', async () => {
|
||||
vi.mocked(api.listUserDataFullInfo).mockResolvedValue([])
|
||||
vi.mocked(api.getGlobalSubgraphs).mockRejectedValue(
|
||||
new Error('global down')
|
||||
)
|
||||
|
||||
await store.fetchSubgraphs()
|
||||
|
||||
expect(store.subgraphBlueprints).toEqual([])
|
||||
})
|
||||
|
||||
it('reports compact detail when more than three blueprints fail', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const addToast = vi.spyOn(useToastStore(), 'add')
|
||||
await mockFetch(
|
||||
{},
|
||||
{
|
||||
a: { name: 'A', info: { node_pack: 'test' }, data: '' },
|
||||
b: { name: 'B', info: { node_pack: 'test' }, data: '' },
|
||||
c: { name: 'C', info: { node_pack: 'test' }, data: '' },
|
||||
d: { name: 'D', info: { node_pack: 'test' }, data: '' }
|
||||
}
|
||||
)
|
||||
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detail: 'x4' })
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores invalid user blueprint files during fetch', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await mockFetch({
|
||||
'invalid.json': {
|
||||
nodes: [],
|
||||
definitions: { subgraphs: [] }
|
||||
}
|
||||
})
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to load subgraph blueprint',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(store.subgraphBlueprints).toHaveLength(0)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects loaded blueprints whose wrapper node does not reference a subgraph', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await mockFetch({
|
||||
'invalid-ref.json': {
|
||||
nodes: [{ id: 1, type: 'missing' }],
|
||||
definitions: { subgraphs: [{ id: 'present' }] }
|
||||
}
|
||||
})
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to load subgraph blueprint',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(store.subgraphBlueprints).toHaveLength(0)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects loaded blueprints without subgraph definitions', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await mockFetch({
|
||||
'missing-definitions.json': {
|
||||
nodes: [{ id: 1, type: 'missing' }]
|
||||
}
|
||||
})
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to load subgraph blueprint',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(store.subgraphBlueprints).toHaveLength(0)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects saving a blueprint whose active state has no subgraph definitions', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
const blueprint = useWorkflowStore().getWorkflowByPath(
|
||||
'subgraphs/test.json'
|
||||
)
|
||||
if (!blueprint?.changeTracker) throw new Error('Blueprint was not loaded')
|
||||
blueprint.changeTracker!.activeState = fromAny<ComfyWorkflowJSON, unknown>({
|
||||
nodes: [{ id: 1, type: '123' }]
|
||||
})
|
||||
|
||||
await expect(blueprint.save()).rejects.toThrow(
|
||||
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
|
||||
)
|
||||
})
|
||||
|
||||
it('marks non-blueprint root nodes when saving an invalid blueprint', async () => {
|
||||
vi.mocked(comfyApp.canvas).draw = vi.fn()
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
const blueprint = useWorkflowStore().getWorkflowByPath(
|
||||
'subgraphs/test.json'
|
||||
)
|
||||
if (!blueprint?.changeTracker) throw new Error('Blueprint was not loaded')
|
||||
blueprint.changeTracker!.activeState = fromAny<ComfyWorkflowJSON, unknown>({
|
||||
nodes: [
|
||||
{ id: 1, type: '123' },
|
||||
{ id: 2, type: 'OtherNode' }
|
||||
],
|
||||
definitions: { subgraphs: [{ id: '123' }] }
|
||||
})
|
||||
|
||||
await expect(blueprint.save()).rejects.toThrow(
|
||||
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
|
||||
)
|
||||
expect(comfyApp.canvas.draw).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('does not save a loaded blueprint when first-save confirmation is cancelled', async () => {
|
||||
const confirm = vi.fn(() => false)
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
prompt: vi.fn(() => 'testname'),
|
||||
confirm
|
||||
})
|
||||
)
|
||||
useSettingStore().settingValues['Comfy.Workflow.WarnBlueprintOverwrite'] =
|
||||
true
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
const blueprint = useWorkflowStore().getWorkflowByPath(
|
||||
'subgraphs/test.json'
|
||||
)
|
||||
if (!blueprint) throw new Error('Blueprint was not loaded')
|
||||
|
||||
const result = await blueprint.save()
|
||||
|
||||
expect(result).toBe(blueprint)
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'overwriteBlueprint',
|
||||
itemList: ['test']
|
||||
})
|
||||
)
|
||||
expect(api.storeUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves a loaded blueprint after first-save confirmation', async () => {
|
||||
const confirm = vi.fn(() => true)
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
prompt: vi.fn(() => 'testname'),
|
||||
confirm
|
||||
})
|
||||
)
|
||||
useSettingStore().settingValues['Comfy.Workflow.WarnBlueprintOverwrite'] =
|
||||
true
|
||||
vi.mocked(api.storeUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
path: 'subgraphs/test.json',
|
||||
modified: Date.now(),
|
||||
size: 2
|
||||
})
|
||||
} as Response)
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
const blueprint = useWorkflowStore().getWorkflowByPath(
|
||||
'subgraphs/test.json'
|
||||
)
|
||||
if (!blueprint) throw new Error('Blueprint was not loaded')
|
||||
|
||||
await blueprint.save()
|
||||
|
||||
const [path, data, options] = vi.mocked(api.storeUserData).mock.calls[0]
|
||||
if (typeof data !== 'string') throw new Error('Expected saved JSON')
|
||||
expect(path).toBe('subgraphs/test.json')
|
||||
expect(JSON.parse(data)).toMatchObject({
|
||||
nodes: [{ type: '123', title: 'test' }],
|
||||
definitions: { subgraphs: [{ id: '123', name: 'test' }] }
|
||||
})
|
||||
expect(options).toEqual({
|
||||
overwrite: true,
|
||||
throwOnError: true,
|
||||
full_info: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an already-loaded blueprint when loading without force', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
const blueprint = useWorkflowStore().getWorkflowByPath(
|
||||
'subgraphs/test.json'
|
||||
)
|
||||
if (!blueprint) throw new Error('Blueprint was not loaded')
|
||||
|
||||
await blueprint.load()
|
||||
|
||||
expect(api.getUserData).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle global blueprint with rejected data promise gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await mockFetch(
|
||||
@@ -406,6 +780,29 @@ describe('useSubgraphStore', () => {
|
||||
expect(nodeDef?.description).toBe('This is a test blueprint')
|
||||
})
|
||||
|
||||
it('does not copy workflowRendererVersion into subgraph metadata on load', async () => {
|
||||
await mockFetch({
|
||||
'metadata-load.json': {
|
||||
nodes: [{ type: '123' }],
|
||||
definitions: {
|
||||
subgraphs: [{ id: '123', extra: {} }]
|
||||
},
|
||||
extra: {
|
||||
BlueprintDescription: 'Loaded description',
|
||||
workflowRendererVersion: 'Vue'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const blueprint = store.getBlueprint(
|
||||
BLUEPRINT_TYPE_PREFIX + 'metadata-load'
|
||||
)
|
||||
|
||||
expect(blueprint.definitions!.subgraphs![0].extra).toEqual({
|
||||
BlueprintDescription: 'Loaded description'
|
||||
})
|
||||
})
|
||||
|
||||
it('should not duplicate metadata in both workflow extra and subgraph extra when publishing', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -415,7 +812,8 @@ describe('useSubgraphStore', () => {
|
||||
// Set metadata on the subgraph's extra (as the commands do)
|
||||
subgraph.extra = {
|
||||
BlueprintDescription: 'Test description',
|
||||
BlueprintSearchAliases: ['alias1', 'alias2']
|
||||
BlueprintSearchAliases: ['alias1', 'alias2'],
|
||||
workflowRendererVersion: 'Vue'
|
||||
}
|
||||
|
||||
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
|
||||
@@ -464,6 +862,7 @@ describe('useSubgraphStore', () => {
|
||||
const subgraphExtra = definitions.subgraphs[0]?.extra
|
||||
expect(subgraphExtra?.BlueprintDescription).toBeUndefined()
|
||||
expect(subgraphExtra?.BlueprintSearchAliases).toBeUndefined()
|
||||
expect(subgraphExtra?.workflowRendererVersion).toBe('Vue')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user