chore: migrate tests from tests-ui/ to colocate with source files (#7811)

## Summary

Migrates all unit tests from `tests-ui/` to colocate with their source
files in `src/`, improving discoverability and maintainability.

## Changes

- **What**: Relocated all unit tests to be adjacent to the code they
test, following the `<source>.test.ts` naming convention
- **Config**: Updated `vitest.config.ts` to remove `tests-ui` include
pattern and `@tests-ui` alias
- **Docs**: Moved testing documentation to `docs/testing/` with updated
paths and patterns

## Review Focus

- Migration patterns documented in
`temp/plans/migrate-tests-ui-to-src.md`
- Tests use `@/` path aliases instead of relative imports
- Shared fixtures placed in `__fixtures__/` directories

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7811-chore-migrate-tests-from-tests-ui-to-colocate-with-source-files-2da6d73d36508147a4cce85365dee614)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-01-05 16:32:24 -08:00
committed by GitHub
parent 832588c7a9
commit 10feb1fd5b
272 changed files with 483 additions and 1239 deletions

View File

@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { getWorkflowFromHistory } from '@/platform/workflow/cloud/getWorkflowFromHistory'
const mockWorkflow: ComfyWorkflowJSON = {
id: 'test-workflow-id',
revision: 0,
last_node_id: 5,
last_link_id: 3,
nodes: [],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
}
const mockHistoryResponse = {
'test-prompt-id': {
prompt: {
priority: 1,
prompt_id: 'test-prompt-id',
extra_data: {
client_id: 'test-client',
extra_pnginfo: {
workflow: mockWorkflow
}
}
},
outputs: {},
status: {
status_str: 'success',
completed: true,
messages: []
}
}
}
describe('getWorkflowFromHistory', () => {
it('should fetch workflow from /history_v2/{prompt_id} endpoint', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => mockHistoryResponse
})
await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(mockFetchApi).toHaveBeenCalledWith('/history_v2/test-prompt-id')
})
it('should extract and return workflow from response', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => mockHistoryResponse
})
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(result).toEqual(mockWorkflow)
})
it('should return undefined when prompt_id not found in response', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => ({})
})
const result = await getWorkflowFromHistory(mockFetchApi, 'nonexistent-id')
expect(result).toBeUndefined()
})
it('should return undefined when workflow is missing from extra_pnginfo', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => ({
'test-prompt-id': {
prompt: {
priority: 1,
prompt_id: 'test-prompt-id',
extra_data: {
client_id: 'test-client'
}
},
outputs: {}
}
})
})
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(result).toBeUndefined()
})
it('should handle fetch errors gracefully', async () => {
const mockFetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(result).toBeUndefined()
})
it('should handle malformed JSON responses', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => {
throw new Error('Invalid JSON')
}
})
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(result).toBeUndefined()
})
})

View File

@@ -0,0 +1,817 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import type {
ComfyWorkflow,
LoadedComfyWorkflow
} from '@/platform/workflow/management/stores/workflowStore'
import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
import { isSubgraph } from '@/utils/typeGuardUtil'
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
api: {
getUserData: vi.fn(),
storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn(),
apiURL: vi.fn(),
addEventListener: vi.fn()
}
}))
// Mock comfyApp globally for the store setup
vi.mock('@/scripts/app', () => ({
app: {
canvas: {} // Start with empty canvas object
}
}))
// Mock isSubgraph
vi.mock('@/utils/typeGuardUtil', () => ({
isSubgraph: vi.fn(() => false)
}))
describe('useWorkflowStore', () => {
let store: ReturnType<typeof useWorkflowStore>
let bookmarkStore: ReturnType<typeof useWorkflowBookmarkStore>
const syncRemoteWorkflows = async (filenames: string[]) => {
vi.mocked(api.listUserDataFullInfo).mockResolvedValue(
filenames.map((filename) => ({
path: filename,
modified: new Date().getTime(),
size: 1 // size !== -1 for remote workflows
}))
)
return await store.syncWorkflows()
}
beforeEach(() => {
setActivePinia(createPinia())
store = useWorkflowStore()
bookmarkStore = useWorkflowBookmarkStore()
vi.clearAllMocks()
// Add default mock implementations
vi.mocked(api.getUserData).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ favorites: [] })
} as Response)
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200
} as Response)
})
describe('syncWorkflows', () => {
it('should sync workflows', async () => {
await syncRemoteWorkflows(['a.json', 'b.json'])
expect(store.workflows.length).toBe(2)
})
it('should exclude temporary workflows', async () => {
const workflow = store.createTemporary('c.json')
await syncRemoteWorkflows(['a.json', 'b.json'])
expect(store.workflows.length).toBe(3)
expect(store.workflows.filter((w) => w.isTemporary)).toEqual([workflow])
})
})
describe('createTemporary', () => {
it('should create a temporary workflow with a unique path', () => {
const workflow = store.createTemporary()
expect(workflow.path).toBe('workflows/Unsaved Workflow.json')
const workflow2 = store.createTemporary()
expect(workflow2.path).toBe('workflows/Unsaved Workflow (2).json')
})
it('should create a temporary workflow not clashing with persisted workflows', async () => {
await syncRemoteWorkflows(['a.json'])
const workflow = store.createTemporary('a.json')
expect(workflow.path).toBe('workflows/a (2).json')
})
})
describe('openWorkflow', () => {
it('should load and open a temporary workflow', async () => {
// Create a test workflow
const workflow = store.createTemporary('test.json')
const mockWorkflowData = { nodes: [], links: [] }
// Mock the load response
vi.spyOn(workflow, 'load').mockImplementation(async () => {
workflow.changeTracker = { activeState: mockWorkflowData } as any
return workflow as LoadedComfyWorkflow
})
// Open the workflow
await store.openWorkflow(workflow)
// Verify the workflow is now active
expect(store.activeWorkflow?.path).toBe(workflow.path)
// Verify the workflow is in the open workflows list
expect(store.isOpen(workflow)).toBe(true)
})
it('should not reload an already active workflow', async () => {
const workflow = await store.createTemporary('test.json').load()
vi.spyOn(workflow, 'load')
// Set as active workflow
store.activeWorkflow = workflow
await store.openWorkflow(workflow)
// Verify load was not called
expect(workflow.load).not.toHaveBeenCalled()
})
it('should load a remote workflow', async () => {
await syncRemoteWorkflows(['a.json'])
const workflow = store.getWorkflowByPath('workflows/a.json')!
expect(workflow).not.toBeNull()
expect(workflow.path).toBe('workflows/a.json')
expect(workflow.isLoaded).toBe(false)
expect(workflow.isTemporary).toBe(false)
vi.mocked(api.getUserData).mockResolvedValue({
status: 200,
text: () => Promise.resolve(defaultGraphJSON)
} as Response)
await workflow.load()
expect(workflow.isLoaded).toBe(true)
expect(workflow.content).toEqual(defaultGraphJSON)
expect(workflow.originalContent).toEqual(defaultGraphJSON)
expect(workflow.activeState).toEqual(defaultGraph)
expect(workflow.initialState).toEqual(defaultGraph)
expect(workflow.isModified).toBe(false)
})
it('should load and open a remote workflow', async () => {
await syncRemoteWorkflows(['a.json', 'b.json'])
const workflow = store.getWorkflowByPath('workflows/a.json')!
expect(workflow).not.toBeNull()
expect(workflow.path).toBe('workflows/a.json')
expect(workflow.isLoaded).toBe(false)
vi.mocked(api.getUserData).mockResolvedValue({
status: 200,
text: () => Promise.resolve(defaultGraphJSON)
} as Response)
const loadedWorkflow = await store.openWorkflow(workflow)
expect(loadedWorkflow).toBe(workflow)
expect(loadedWorkflow.path).toBe('workflows/a.json')
expect(store.activeWorkflow?.path).toBe('workflows/a.json')
expect(store.isOpen(loadedWorkflow)).toBe(true)
expect(loadedWorkflow.content).toEqual(defaultGraphJSON)
expect(loadedWorkflow.originalContent).toEqual(defaultGraphJSON)
expect(loadedWorkflow.isLoaded).toBe(true)
expect(loadedWorkflow.activeState).toEqual(defaultGraph)
expect(loadedWorkflow.initialState).toEqual(defaultGraph)
expect(loadedWorkflow.isModified).toBe(false)
})
})
describe('openWorkflowsInBackground', () => {
let workflowA: ComfyWorkflow
let workflowB: ComfyWorkflow
let workflowC: ComfyWorkflow
const openWorkflowPaths = () =>
store.openWorkflows.filter((w) => store.isOpen(w)).map((w) => w.path)
beforeEach(async () => {
await syncRemoteWorkflows(['a.json', 'b.json', 'c.json'])
workflowA = store.getWorkflowByPath('workflows/a.json')!
workflowB = store.getWorkflowByPath('workflows/b.json')!
workflowC = store.getWorkflowByPath('workflows/c.json')!
vi.mocked(api.getUserData).mockResolvedValue({
status: 200,
text: () => Promise.resolve(defaultGraphJSON)
} as Response)
})
it('should open workflows adjacent to the active workflow', async () => {
await store.openWorkflow(workflowA)
store.openWorkflowsInBackground({
left: [workflowB.path],
right: [workflowC.path]
})
expect(openWorkflowPaths()).toEqual([
workflowB.path,
workflowA.path,
workflowC.path
])
})
it('should not change the active workflow', async () => {
await store.openWorkflow(workflowA)
store.openWorkflowsInBackground({
left: [workflowC.path],
right: [workflowB.path]
})
expect(store.activeWorkflow).not.toBeUndefined()
expect(store.activeWorkflow!.path).toBe(workflowA.path)
})
it('should open workflows when none are active', async () => {
expect(store.openWorkflows.length).toBe(0)
store.openWorkflowsInBackground({
left: [workflowA.path],
right: [workflowB.path]
})
expect(openWorkflowPaths()).toEqual([workflowA.path, workflowB.path])
})
it('should not open duplicate workflows', async () => {
store.openWorkflowsInBackground({
left: [workflowA.path, workflowB.path, workflowA.path],
right: [workflowB.path, workflowA.path, workflowB.path]
})
expect(openWorkflowPaths()).toEqual([workflowA.path, workflowB.path])
})
it('should not open workflow that is already open', async () => {
await store.openWorkflow(workflowA)
store.openWorkflowsInBackground({
left: [workflowA.path]
})
expect(openWorkflowPaths()).toEqual([workflowA.path])
expect(store.activeWorkflow?.path).toBe(workflowA.path)
})
it('should ignore invalid or deleted workflow paths', async () => {
await store.openWorkflow(workflowA)
store.openWorkflowsInBackground({
left: ['workflows/invalid::$-path.json'],
right: ['workflows/deleted-since-last-session.json']
})
expect(openWorkflowPaths()).toEqual([workflowA.path])
expect(store.activeWorkflow?.path).toBe(workflowA.path)
})
it('should do nothing when given an empty argument', async () => {
await store.openWorkflow(workflowA)
store.openWorkflowsInBackground({})
expect(openWorkflowPaths()).toEqual([workflowA.path])
store.openWorkflowsInBackground({ left: [], right: [] })
expect(openWorkflowPaths()).toEqual([workflowA.path])
expect(store.activeWorkflow?.path).toBe(workflowA.path)
})
})
describe('renameWorkflow', () => {
it('should rename workflow and update bookmarks', async () => {
const workflow = store.createTemporary('dir/test.json')
// Set up initial bookmark
expect(workflow.path).toBe('workflows/dir/test.json')
await bookmarkStore.setBookmarked(workflow.path, true)
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(true)
// Mock super.rename
vi.spyOn(Object.getPrototypeOf(workflow), 'rename').mockImplementation(
async function (this: any, newPath: string) {
this.path = newPath
return this
} as any
)
// Perform rename
const newPath = 'workflows/dir/renamed.json'
await store.renameWorkflow(workflow, newPath)
// Check that bookmark was transferred
expect(bookmarkStore.isBookmarked(newPath)).toBe(true)
expect(bookmarkStore.isBookmarked('workflows/dir/test.json')).toBe(false)
})
it('should rename workflow without affecting bookmarks if not bookmarked', async () => {
const workflow = store.createTemporary('test.json')
// Verify not bookmarked initially
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(false)
// Mock super.rename
vi.spyOn(Object.getPrototypeOf(workflow), 'rename').mockImplementation(
async function (this: any, newPath: string) {
this.path = newPath
return this
} as any
)
// Perform rename
const newName = 'renamed'
await workflow.rename(newName)
// Check that no bookmarks were affected
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(false)
expect(bookmarkStore.isBookmarked('test.json')).toBe(false)
})
})
describe('closeWorkflow', () => {
it('should close a workflow', async () => {
const workflow = store.createTemporary('test.json')
await store.openWorkflow(workflow)
expect(store.isOpen(workflow)).toBe(true)
expect(store.getWorkflowByPath(workflow.path)).not.toBeNull()
await store.closeWorkflow(workflow)
expect(store.isOpen(workflow)).toBe(false)
expect(store.getWorkflowByPath(workflow.path)).toBeNull()
})
})
describe('deleteWorkflow', () => {
it('should close and delete an open workflow', async () => {
const workflow = store.createTemporary('test.json')
// Mock the necessary methods
vi.spyOn(workflow, 'delete').mockResolvedValue()
// Open the workflow first
await store.openWorkflow(workflow)
// Delete the workflow
await store.deleteWorkflow(workflow)
// Verify workflow was closed and deleted
expect(workflow.delete).toHaveBeenCalled()
})
it('should remove bookmark when deleting a bookmarked workflow', async () => {
const workflow = store.createTemporary('test.json')
// Mock delete method
vi.spyOn(workflow, 'delete').mockResolvedValue()
// Bookmark the workflow
await bookmarkStore.setBookmarked(workflow.path, true)
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(true)
// Delete the workflow
await store.deleteWorkflow(workflow)
// Verify bookmark was removed
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(false)
})
})
describe('save', () => {
it('should save workflow content and reset modification state', async () => {
await syncRemoteWorkflows(['test.json'])
const workflow = store.getWorkflowByPath('workflows/test.json')!
// Mock the activeState
const mockState = { nodes: [] }
workflow.changeTracker = {
activeState: mockState,
reset: vi.fn()
} as any
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () =>
Promise.resolve({
path: 'workflows/test.json',
modified: Date.now(),
size: 2
})
} as Response)
// Save the workflow
await workflow.save()
// Verify the content was updated
expect(workflow.content).toBe(JSON.stringify(mockState))
expect(workflow.changeTracker!.reset).toHaveBeenCalled()
expect(workflow.isModified).toBe(false)
})
it('should save workflow even if isModified is screwed by changeTracker', async () => {
await syncRemoteWorkflows(['test.json'])
const workflow = store.getWorkflowByPath('workflows/test.json')!
workflow.isModified = false
// Mock the activeState
const mockState = { nodes: [] }
workflow.changeTracker = {
activeState: mockState,
reset: vi.fn()
} as any
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () =>
Promise.resolve({
path: 'workflows/test.json',
modified: Date.now(),
size: 2
})
} as Response)
// Save the workflow
await workflow.save()
// Verify storeUserData was called
expect(api.storeUserData).toHaveBeenCalled()
// Verify the content was updated
expect(workflow.changeTracker!.reset).toHaveBeenCalled()
expect(workflow.isModified).toBe(false)
})
})
describe('saveAs', () => {
it('should save workflow to new path and reset modification state', async () => {
await syncRemoteWorkflows(['test.json'])
const workflow = store.getWorkflowByPath('workflows/test.json')!
workflow.isModified = true
// Mock the activeState
const mockState = { nodes: [] }
workflow.changeTracker = {
activeState: mockState,
reset: vi.fn()
} as any
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () =>
Promise.resolve({
path: 'workflows/new-test.json',
modified: Date.now(),
size: 2
})
} as Response)
// Save the workflow with new path
const newWorkflow = await workflow.saveAs('workflows/new-test.json')
// Verify the content was updated
expect(workflow.path).toBe('workflows/test.json')
expect(workflow.isModified).toBe(true)
expect(newWorkflow.path).toBe('workflows/new-test.json')
expect(newWorkflow.content).toBe(JSON.stringify(mockState))
expect(newWorkflow.isModified).toBe(false)
})
})
describe('Subgraphs', () => {
beforeEach(async () => {
// Ensure canvas exists for these tests
vi.mocked(comfyApp).canvas = { subgraph: null } as any
// Setup an active workflow as updateActiveGraph depends on it
const workflow = store.createTemporary('test-subgraph-workflow.json')
// Mock load to avoid actual file operations/parsing
vi.spyOn(workflow, 'load').mockImplementation(async () => {
workflow.changeTracker = { activeState: {} } as any // Minimal mock
workflow.originalContent = '{}'
workflow.content = '{}'
return workflow as LoadedComfyWorkflow
})
await store.openWorkflow(workflow)
// Reset mocks before each subgraph test
vi.mocked(comfyApp.canvas).subgraph = undefined // Use undefined for root graph
})
it('should handle when comfyApp.canvas is not available', async () => {
// Arrange
vi.mocked(comfyApp).canvas = null as any // Simulate canvas not ready
// Act
console.debug(store.isSubgraphActive)
store.updateActiveGraph()
await nextTick()
// Assert
console.debug(store.isSubgraphActive)
expect(store.isSubgraphActive).toBe(false) // Should default to false
expect(store.activeSubgraph).toBeUndefined() // Should default to empty
})
it('should correctly update state when the root graph is active', async () => {
// Arrange: Ensure comfyApp indicates root graph is active
vi.mocked(comfyApp.canvas).subgraph = undefined // Use undefined for root graph
// Act: Trigger the update
store.updateActiveGraph()
await nextTick() // Wait for Vue reactivity
// Assert: Check store state
expect(store.isSubgraphActive).toBe(false)
expect(store.activeSubgraph).toBeUndefined()
})
it('should correctly update state when a subgraph is active', async () => {
// Arrange: Setup mock subgraph structure
const mockSubgraph = {
name: 'Level 2 Subgraph',
isRootGraph: false,
pathToRootGraph: [
{ name: 'Root' }, // Root Graph (index 0, ignored)
{ name: 'Level 1 Subgraph' },
{ name: 'Level 2 Subgraph' }
]
} as any
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph
// Mock isSubgraph to return true for our mockSubgraph
vi.mocked(isSubgraph).mockImplementation(
(obj): obj is Subgraph => obj === mockSubgraph
)
// Act: Trigger the update
store.updateActiveGraph()
await nextTick() // Wait for Vue reactivity
// Assert: Check store state
expect(store.isSubgraphActive).toBe(true)
expect(store.activeSubgraph).toEqual(mockSubgraph)
})
it('should update automatically when activeWorkflow changes', async () => {
// Arrange: Set initial canvas state (e.g., a subgraph)
const initialSubgraph = {
name: 'Initial Subgraph',
pathToRootGraph: [{ name: 'Root' }, { name: 'Initial Subgraph' }],
isRootGraph: false
} as any
vi.mocked(comfyApp.canvas).subgraph = initialSubgraph
// Mock isSubgraph to return true for our initialSubgraph
vi.mocked(isSubgraph).mockImplementation(
(obj): obj is Subgraph => obj === initialSubgraph
)
// Trigger initial update based on the *first* workflow opened in beforeEach
store.updateActiveGraph()
await nextTick()
// Verify initial state
expect(store.isSubgraphActive).toBe(true)
expect(store.activeSubgraph).toEqual(initialSubgraph)
// Act: Change the active workflow
const workflow2 = store.createTemporary('workflow2.json')
// Mock load for the second workflow
vi.spyOn(workflow2, 'load').mockImplementation(async () => {
workflow2.changeTracker = { activeState: {} } as any
workflow2.originalContent = '{}'
workflow2.content = '{}'
return workflow2 as LoadedComfyWorkflow
})
// Before changing workflow, set the canvas state to something different (e.g., root)
// This ensures the watcher *does* cause a state change we can assert
vi.mocked(comfyApp.canvas).subgraph = undefined
// Mock isSubgraph to return false for undefined
vi.mocked(isSubgraph).mockImplementation(
(_obj): _obj is Subgraph => false
)
await store.openWorkflow(workflow2) // This changes activeWorkflow and triggers the watch
await nextTick() // Allow watcher and potential async operations in updateActiveGraph to complete
// Assert: Check that the state was updated by the watcher based on the *new* canvas state
expect(store.isSubgraphActive).toBe(false) // Should reflect the change to undefined subgraph
expect(store.activeSubgraph).toBeUndefined()
})
})
describe('NodeLocatorId conversions', () => {
beforeEach(() => {
// Setup mock graph structure with subgraphs
const mockSubgraph = {
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
rootGraph: null as any,
_nodes: [],
nodes: []
}
const mockNode = {
id: 123,
isSubgraphNode: () => true,
subgraph: mockSubgraph
}
const mockRootGraph = {
_nodes: [mockNode],
nodes: [mockNode],
subgraphs: new Map([[mockSubgraph.id, mockSubgraph]]),
getNodeById: (id: string | number) => {
if (String(id) === '123') return mockNode
return null
}
}
mockSubgraph.rootGraph = mockRootGraph as any
vi.mocked(comfyApp).rootGraph = mockRootGraph as any
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any
store.activeSubgraph = mockSubgraph as any
})
describe('nodeIdToNodeLocatorId', () => {
it('should convert node ID to NodeLocatorId for subgraph nodes', () => {
const result = store.nodeIdToNodeLocatorId(456)
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
})
it('should return simple node ID for root graph nodes', () => {
store.activeSubgraph = undefined
const result = store.nodeIdToNodeLocatorId(123)
expect(result).toBe('123')
})
it('should use provided subgraph instead of active one', () => {
const customSubgraph = {
id: 'custom-uuid-1234-5678-90ab-cdef12345678'
} as any
const result = store.nodeIdToNodeLocatorId(789, customSubgraph)
expect(result).toBe('custom-uuid-1234-5678-90ab-cdef12345678:789')
})
})
describe('nodeExecutionIdToNodeLocatorId', () => {
it('should convert execution ID to NodeLocatorId', () => {
const result = store.nodeExecutionIdToNodeLocatorId('123:456')
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
})
it('should return simple node ID for root level nodes', () => {
const result = store.nodeExecutionIdToNodeLocatorId('123')
expect(result).toBe('123')
})
it('should return null for invalid execution IDs', () => {
const result = store.nodeExecutionIdToNodeLocatorId('999:456')
expect(result).toBeNull()
})
})
describe('nodeLocatorIdToNodeId', () => {
it('should extract node ID from NodeLocatorId', () => {
const result = store.nodeLocatorIdToNodeId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
)
expect(result).toBe(456)
})
it('should handle string node IDs', () => {
const result = store.nodeLocatorIdToNodeId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:node_1'
)
expect(result).toBe('node_1')
})
it('should handle simple node IDs (root graph)', () => {
const result = store.nodeLocatorIdToNodeId('123')
expect(result).toBe(123)
const stringResult = store.nodeLocatorIdToNodeId('node_1')
expect(stringResult).toBe('node_1')
})
it('should return null for invalid NodeLocatorId', () => {
const result = store.nodeLocatorIdToNodeId('invalid:format')
expect(result).toBeNull()
})
})
describe('nodeLocatorIdToNodeExecutionId', () => {
it('should convert NodeLocatorId to execution ID', () => {
// Need to mock isSubgraph to identify our mockSubgraph
vi.mocked(isSubgraph).mockImplementation((obj): obj is Subgraph => {
return obj === store.activeSubgraph
})
const result = store.nodeLocatorIdToNodeExecutionId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
)
expect(result).toBe('123:456')
})
it('should handle simple node IDs (root graph)', () => {
const result = store.nodeLocatorIdToNodeExecutionId('123')
expect(result).toBe('123')
})
it('should return null for unknown subgraph UUID', () => {
const result = store.nodeLocatorIdToNodeExecutionId(
'unknown-uuid-1234-5678-90ab-cdef12345678:456'
)
expect(result).toBeNull()
})
it('should return null for invalid NodeLocatorId', () => {
const result = store.nodeLocatorIdToNodeExecutionId('invalid:format')
expect(result).toBeNull()
})
})
})
describe('Tab Activation History', () => {
let workflowA: ComfyWorkflow
let workflowB: ComfyWorkflow
let workflowC: ComfyWorkflow
beforeEach(async () => {
await syncRemoteWorkflows(['a.json', 'b.json', 'c.json'])
workflowA = store.getWorkflowByPath('workflows/a.json')!
workflowB = store.getWorkflowByPath('workflows/b.json')!
workflowC = store.getWorkflowByPath('workflows/c.json')!
vi.mocked(api.getUserData).mockResolvedValue({
status: 200,
text: () => Promise.resolve(defaultGraphJSON)
} as Response)
})
it('should return most recently active workflow', async () => {
// Open workflows in order: A -> B -> C
await store.openWorkflow(workflowA)
await store.openWorkflow(workflowB)
await store.openWorkflow(workflowC)
// C is current, B should be most recent
const mostRecent = store.getMostRecentWorkflow()
expect(mostRecent?.path).toBe(workflowB.path)
})
it('should skip closed workflows (lazy cleanup)', async () => {
// Open workflows: A -> B -> C
await store.openWorkflow(workflowA)
await store.openWorkflow(workflowB)
await store.openWorkflow(workflowC)
// Close B (the most recent before C)
await store.closeWorkflow(workflowB)
// C is current, B is closed, so A should be returned
const mostRecent = store.getMostRecentWorkflow()
expect(mostRecent?.path).toBe(workflowA.path)
})
it('should return null when no valid history exists', async () => {
// Open only one workflow
await store.openWorkflow(workflowA)
// No previous workflows, should return null
const mostRecent = store.getMostRecentWorkflow()
expect(mostRecent).toBeNull()
})
it('should track history when opening workflows', async () => {
// Open A, then B, then A again
await store.openWorkflow(workflowA)
await store.openWorkflow(workflowB)
await store.openWorkflow(workflowA)
// A is current, B should be most recent
const mostRecent = store.getMostRecentWorkflow()
expect(mostRecent?.path).toBe(workflowB.path)
})
it('should handle workflow activated multiple times', async () => {
// Open: A -> B -> A -> C
await store.openWorkflow(workflowA)
await store.openWorkflow(workflowB)
await store.openWorkflow(workflowA)
await store.openWorkflow(workflowC)
// C is current, A should be most recent (not B)
const mostRecent = store.getMostRecentWorkflow()
expect(mostRecent?.path).toBe(workflowA.path)
})
it('should clean up history when all previous workflows are closed', async () => {
// Open: A -> B -> C
await store.openWorkflow(workflowA)
await store.openWorkflow(workflowB)
await store.openWorkflow(workflowC)
// Close A and B
await store.closeWorkflow(workflowA)
await store.closeWorkflow(workflowB)
// C is current, no valid history
const mostRecent = store.getMostRecentWorkflow()
expect(mostRecent).toBeNull()
})
})
})

View File

@@ -0,0 +1,296 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { api } from '@/scripts/api'
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: vi.fn(() => ({
saveWorkflow: vi.fn()
}))
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key) => {
if (key === 'Comfy.Workflow.AutoSave') return mockAutoSaveSetting
if (key === 'Comfy.Workflow.AutoSaveDelay') return mockAutoSaveDelay
return null
})
}))
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeWorkflow: mockActiveWorkflow
}))
}))
let mockAutoSaveSetting: string = 'off'
let mockAutoSaveDelay: number = 1000
let mockActiveWorkflow: { isModified: boolean; isPersisted?: boolean } | null =
null
describe('useWorkflowAutoSave', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should auto-save workflow after delay when modified and autosave enabled', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 1000
mockActiveWorkflow = { isModified: true, isPersisted: true }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
vi.advanceTimersByTime(1000)
const serviceInstance = (useWorkflowService as any).mock.results[0].value
expect(serviceInstance.saveWorkflow).toHaveBeenCalledWith(
mockActiveWorkflow
)
})
it('should not auto-save workflow after delay when not modified and autosave enabled', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 1000
mockActiveWorkflow = { isModified: false, isPersisted: true }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
vi.advanceTimersByTime(1000)
const serviceInstance = (useWorkflowService as any).mock.results[0].value
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalledWith(
mockActiveWorkflow
)
})
it('should not auto save workflow when autosave is off', async () => {
mockAutoSaveSetting = 'off'
mockAutoSaveDelay = 1000
mockActiveWorkflow = { isModified: true, isPersisted: true }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
vi.advanceTimersByTime(mockAutoSaveDelay)
const serviceInstance = (useWorkflowService as any).mock.results[0].value
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalled()
})
it('should respect the user specified auto save delay', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 2000
mockActiveWorkflow = { isModified: true, isPersisted: true }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
vi.advanceTimersByTime(1000)
const serviceInstance = (useWorkflowService as any).mock.results[0].value
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(serviceInstance.saveWorkflow).toHaveBeenCalled()
})
it('should debounce save requests', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 2000
mockActiveWorkflow = { isModified: true, isPersisted: true }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
const serviceInstance = (useWorkflowService as any).mock.results[0].value
const graphChangedCallback = (api.addEventListener as any).mock.calls[0][1]
graphChangedCallback()
vi.advanceTimersByTime(500)
graphChangedCallback()
vi.advanceTimersByTime(1999)
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(serviceInstance.saveWorkflow).toHaveBeenCalledTimes(1)
})
it('should handle save error gracefully', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 1000
mockActiveWorkflow = { isModified: true, isPersisted: true }
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
try {
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
const serviceInstance = (useWorkflowService as any).mock.results[0].value
serviceInstance.saveWorkflow.mockRejectedValue(new Error('Test Error'))
vi.advanceTimersByTime(1000)
await Promise.resolve()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Auto save failed:',
expect.any(Error)
)
} finally {
consoleErrorSpy.mockRestore()
}
})
it('should queue autosave requests during saving and reschedule after save completes', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 1000
mockActiveWorkflow = { isModified: true, isPersisted: true }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
const serviceInstance = (useWorkflowService as any).mock.results[0].value
let resolveSave: () => void
const firstSavePromise = new Promise<void>((resolve) => {
resolveSave = resolve
})
serviceInstance.saveWorkflow.mockImplementationOnce(() => firstSavePromise)
vi.advanceTimersByTime(1000)
const graphChangedCallback = (api.addEventListener as any).mock.calls[0][1]
graphChangedCallback()
resolveSave!()
await Promise.resolve()
vi.advanceTimersByTime(1000)
expect(serviceInstance.saveWorkflow).toHaveBeenCalledTimes(2)
})
it('should clean up event listeners on component unmount', async () => {
mockAutoSaveSetting = 'after delay'
const wrapper = mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
wrapper.unmount()
expect(api.removeEventListener).toHaveBeenCalled()
})
it('should handle edge case delay values properly', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 0
mockActiveWorkflow = { isModified: true, isPersisted: true }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
await vi.runAllTimersAsync()
const serviceInstance = (useWorkflowService as any).mock.results[0].value
expect(serviceInstance.saveWorkflow).toHaveBeenCalledTimes(1)
serviceInstance.saveWorkflow.mockClear()
mockAutoSaveDelay = -500
const graphChangedCallback = (api.addEventListener as any).mock.calls[0][1]
graphChangedCallback()
await vi.runAllTimersAsync()
expect(serviceInstance.saveWorkflow).toHaveBeenCalledTimes(1)
})
it('should not autosave if workflow is not persisted', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 1000
mockActiveWorkflow = { isModified: true, isPersisted: false }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
vi.advanceTimersByTime(1000)
const serviceInstance = (useWorkflowService as any).mock.results[0].value
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalledWith(
mockActiveWorkflow
)
})
})

View File

@@ -0,0 +1,403 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
/**
* Unit tests for useTemplateUrlLoader composable
*
* Tests the behavior of loading templates via URL query parameters:
* - ?template=flux_simple loads the template
* - ?template=flux_simple&source=custom loads from custom source
* - ?template=flux_simple&mode=linear loads template in linear mode
* - Invalid template shows error toast
* - Input validation for template, source, and mode parameters
*/
const preservedQueryMocks = vi.hoisted(() => ({
clearPreservedQuery: vi.fn()
}))
// Mock vue-router
let mockQueryParams: Record<string, string | undefined> = {}
const mockRouterReplace = vi.fn()
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
query: mockQueryParams
})),
useRouter: vi.fn(() => ({
replace: mockRouterReplace
}))
}))
vi.mock(
'@/platform/navigation/preservedQueryManager',
() => preservedQueryMocks
)
// Mock template workflows composable
const mockLoadTemplates = vi.fn().mockResolvedValue(true)
const mockLoadWorkflowTemplate = vi.fn().mockResolvedValue(true)
vi.mock(
'@/platform/workflow/templates/composables/useTemplateWorkflows',
() => ({
useTemplateWorkflows: () => ({
loadTemplates: mockLoadTemplates,
loadWorkflowTemplate: mockLoadWorkflowTemplate
})
})
)
// Mock toast
const mockToastAdd = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
// Mock i18n
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn((key: string, params?: any) => {
if (key === 'g.error') return 'Error'
if (key === 'templateWorkflows.error.templateNotFound') {
return `Template "${params?.templateName}" not found`
}
if (key === 'g.errorLoadingTemplate') return 'Failed to load template'
return key
})
})
}))
// Mock canvas store
const mockCanvasStore = {
linearMode: false
}
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
describe('useTemplateUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryParams = {}
mockCanvasStore.linearMode = false
})
it('does not load template when no query param present', () => {
mockQueryParams = {}
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
expect(mockLoadTemplates).not.toHaveBeenCalled()
expect(mockLoadWorkflowTemplate).not.toHaveBeenCalled()
})
it('loads template when query param is present', async () => {
mockQueryParams = { template: 'flux_simple' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadTemplates).toHaveBeenCalledTimes(1)
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledTimes(1)
})
it('uses default source when source param is not provided', async () => {
mockQueryParams = { template: 'flux_simple' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
})
it('uses custom source when source param is provided', async () => {
mockQueryParams = { template: 'custom-template', source: 'custom-module' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'custom-template',
'custom-module'
)
})
it('shows error toast when template loading fails', async () => {
mockQueryParams = { template: 'invalid-template' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Template "invalid-template" not found',
life: 3000
})
})
it('handles array query params correctly', () => {
// Vue Router can return string[] for duplicate params
mockQueryParams = { template: ['first', 'second'] as any }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
// Should not load when param is an array
expect(mockLoadTemplates).not.toHaveBeenCalled()
})
it('rejects invalid template parameter with special characters', () => {
// Test path traversal attempt
mockQueryParams = { template: '../../../etc/passwd' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
// Should not load invalid template
expect(mockLoadTemplates).not.toHaveBeenCalled()
})
it('rejects invalid template parameter with slash', () => {
mockQueryParams = { template: 'path/to/template' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
// Should not load invalid template
expect(mockLoadTemplates).not.toHaveBeenCalled()
})
it('accepts valid template parameter formats', async () => {
const validTemplates = [
'flux_simple',
'flux-kontext-dev',
'template123',
'My_Template-2',
'templates-1_click_multiple_scene_angles-v1.0' // template with version number containing dot
]
for (const template of validTemplates) {
vi.clearAllMocks()
mockQueryParams = { template }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(template, 'default')
}
})
it('rejects invalid source parameter with special characters', () => {
mockQueryParams = { template: 'flux_simple', source: '../malicious' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
// Should not load with invalid source
expect(mockLoadTemplates).not.toHaveBeenCalled()
})
it('accepts valid source parameter formats', async () => {
const validSources = ['default', 'custom-module', 'my_source', 'source123']
for (const source of validSources) {
vi.clearAllMocks()
mockQueryParams = { template: 'flux_simple', source }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
source
)
}
})
it('shows error toast when exception is thrown', async () => {
mockQueryParams = { template: 'flux_simple' }
mockLoadTemplates.mockRejectedValueOnce(new Error('Network error'))
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Failed to load template',
life: 3000
})
})
it('removes template params from URL after successful load', async () => {
mockQueryParams = {
template: 'flux_simple',
source: 'custom',
mode: 'linear',
other: 'param'
}
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
})
it('removes template params from URL even on error', async () => {
mockQueryParams = { template: 'invalid', source: 'custom', other: 'param' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
})
it('removes template params from URL even on exception', async () => {
mockQueryParams = { template: 'flux_simple', other: 'param' }
mockLoadTemplates.mockRejectedValueOnce(new Error('Network error'))
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
})
it('sets linear mode when mode=linear and template loads successfully', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'linear' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(true)
})
it('does not set linear mode when template loading fails', async () => {
mockQueryParams = { template: 'invalid-template', mode: 'linear' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockCanvasStore.linearMode).toBe(false)
})
it('does not set linear mode when mode parameter is not linear', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'graph' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(false)
})
it('rejects invalid mode parameter with special characters', () => {
mockQueryParams = { template: 'flux_simple', mode: '../malicious' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
expect(mockLoadTemplates).not.toHaveBeenCalled()
})
it('handles array mode params correctly', () => {
// Vue Router can return string[] for duplicate params
mockQueryParams = {
template: 'flux_simple',
mode: ['linear', 'graph'] as any
}
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
// Should not load when mode param is an array
expect(mockLoadTemplates).not.toHaveBeenCalled()
})
it('warns about unsupported mode values but continues loading', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockQueryParams = { template: 'flux_simple', mode: 'unsupported' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(consoleSpy).toHaveBeenCalledWith(
'[useTemplateUrlLoader] Unsupported mode parameter: unsupported. Supported modes: linear'
)
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(false)
consoleSpy.mockRestore()
})
it('accepts supported mode parameter: linear', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'linear' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(true)
})
it('accepts valid format but warns about unsupported modes', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const unsupportedModes = ['graph', 'mode123', 'my_mode-2']
for (const mode of unsupportedModes) {
vi.clearAllMocks()
consoleSpy.mockClear()
mockCanvasStore.linearMode = false
mockQueryParams = { template: 'flux_simple', mode }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(consoleSpy).toHaveBeenCalledWith(
`[useTemplateUrlLoader] Unsupported mode parameter: ${mode}. Supported modes: linear`
)
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(false)
}
consoleSpy.mockRestore()
})
})

View File

@@ -0,0 +1,303 @@
import { flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
// Mock the store
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: vi.fn()
})
)
// Mock the API
vi.mock('@/scripts/api', () => ({
api: {
fileURL: vi.fn((path) => `mock-file-url${path}`),
apiURL: vi.fn((path) => `mock-api-url${path}`)
}
}))
// Mock the app
vi.mock('@/scripts/app', () => ({
app: {
loadGraphData: vi.fn()
}
}))
// Mock Vue I18n
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn((key, fallback) => fallback || key)
}),
createI18n: () => ({
global: {
t: (key: string) => key
}
})
}))
// Mock the dialog store
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
closeDialog: vi.fn()
}))
}))
// Mock fetch
global.fetch = vi.fn()
describe('useTemplateWorkflows', () => {
let mockWorkflowTemplatesStore: any
beforeEach(() => {
mockWorkflowTemplatesStore = {
isLoaded: false,
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
groupedTemplates: [
{
label: 'ComfyUI Examples',
modules: [
{
moduleName: 'all',
title: 'All',
localizedTitle: 'All Templates',
templates: [
{
name: 'template1',
mediaType: 'image',
mediaSubtype: 'jpg',
sourceModule: 'default',
localizedTitle: 'Template 1'
},
{
name: 'template2',
mediaType: 'image',
mediaSubtype: 'jpg',
sourceModule: 'custom-module',
description: 'A custom template'
}
]
},
{
moduleName: 'default',
title: 'Default',
localizedTitle: 'Default Templates',
templates: [
{
name: 'template1',
mediaType: 'image',
mediaSubtype: 'jpg',
localizedTitle: 'Template 1',
localizedDescription: 'A default template'
}
]
}
]
}
]
}
vi.mocked(useWorkflowTemplatesStore).mockReturnValue(
mockWorkflowTemplatesStore
)
// Mock fetch response
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ workflow: 'data' })
} as unknown as Response)
})
it('should load templates from store', async () => {
const { loadTemplates, isTemplatesLoaded } = useTemplateWorkflows()
expect(isTemplatesLoaded.value).toBe(false)
await loadTemplates()
expect(mockWorkflowTemplatesStore.loadWorkflowTemplates).toHaveBeenCalled()
})
it('should select the first template category', () => {
const { selectFirstTemplateCategory, selectedTemplate } =
useTemplateWorkflows()
selectFirstTemplateCategory()
expect(selectedTemplate.value).toEqual(
mockWorkflowTemplatesStore.groupedTemplates[0].modules[0]
)
})
it('should select a template category', () => {
const { selectTemplateCategory, selectedTemplate } = useTemplateWorkflows()
const category = mockWorkflowTemplatesStore.groupedTemplates[0].modules[1] // Default category
const result = selectTemplateCategory(category)
expect(result).toBe(true)
expect(selectedTemplate.value).toEqual(category)
})
it('should format template thumbnails correctly for default templates', () => {
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
const template = {
name: 'test-template',
mediaSubtype: 'jpg',
mediaType: 'image',
description: 'Test template'
}
const url = getTemplateThumbnailUrl(template, 'default', '1')
expect(url).toBe('mock-file-url/templates/test-template-1.jpg')
})
it('should format template thumbnails correctly for custom templates', () => {
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
const template = {
name: 'test-template',
mediaSubtype: 'jpg',
mediaType: 'image',
description: 'Test template'
}
const url = getTemplateThumbnailUrl(template, 'custom-module')
expect(url).toBe(
'mock-api-url/workflow_templates/custom-module/test-template.jpg'
)
})
it('should format template titles correctly', () => {
const { getTemplateTitle } = useTemplateWorkflows()
// Default template with localized title
const titleWithLocalized = getTemplateTitle(
{
name: 'test',
localizedTitle: 'Localized Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'default'
)
expect(titleWithLocalized).toBe('Localized Title')
// Default template without localized title
const titleWithFallback = getTemplateTitle(
{
name: 'test',
title: 'Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'default'
)
expect(titleWithFallback).toBe('Title')
// Custom template
const customTitle = getTemplateTitle(
{
name: 'test-template',
title: 'Custom Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'custom-module'
)
expect(customTitle).toBe('Custom Title')
// Fallback to name
const nameOnly = getTemplateTitle(
{
name: 'name-only',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'custom-module'
)
expect(nameOnly).toBe('name-only')
})
it('should format template descriptions correctly', () => {
const { getTemplateDescription } = useTemplateWorkflows()
// Default template with localized description
const descWithLocalized = getTemplateDescription({
name: 'test',
localizedDescription: 'Localized Description',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
})
expect(descWithLocalized).toBe('Localized Description')
// Custom template with description
const customDesc = getTemplateDescription({
name: 'test',
description: 'custom-template_description',
mediaType: 'image',
mediaSubtype: 'jpg'
})
expect(customDesc).toBe('custom template description')
})
it('should load a template from the "All" category', async () => {
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()
// Set the store as loaded
mockWorkflowTemplatesStore.isLoaded = true
// Load a template from the "All" category
const result = await loadWorkflowTemplate('template1', 'all')
await flushPromises()
expect(result).toBe(true)
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
expect(loadingTemplateId.value).toBe(null) // Should reset after loading
})
it('should load a template from a regular category', async () => {
const { loadWorkflowTemplate } = useTemplateWorkflows()
// Set the store as loaded
mockWorkflowTemplatesStore.isLoaded = true
// Load a template from the default category
const result = await loadWorkflowTemplate('template1', 'default')
await flushPromises()
expect(result).toBe(true)
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
})
it('should handle errors when loading templates', async () => {
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()
// Set the store as loaded
mockWorkflowTemplatesStore.isLoaded = true
// Mock fetch to throw an error
vi.mocked(fetch).mockRejectedValueOnce(new Error('Failed to fetch'))
// Spy on console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// Load a template that will fail
const result = await loadWorkflowTemplate('error-template', 'default')
expect(result).toBe(false)
expect(consoleSpy).toHaveBeenCalled()
expect(loadingTemplateId.value).toBe(null) // Should reset even after error
// Restore console.error
consoleSpy.mockRestore()
})
})

View File

@@ -0,0 +1,376 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
413,
389
],
"size": {
"0": 425.27801513671875,
"1": 180.6060791015625
},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
6
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"text, watermark"
]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
415,
186
],
"size": {
"0": 422.84503173828125,
"1": 164.31304931640625
},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
4
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [
473,
609
],
"size": {
"0": 315,
"1": 106
},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
2
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
512,
512,
1
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
863,
186
],
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 1
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
7
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
1209,
188
],
"size": {
"0": 210,
"1": 46
},
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
9
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1451,
189
],
"size": {
"0": 210,
"1": 58
},
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
26,
474
],
"size": {
"0": 315,
"1": 98
},
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
1
],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [
3,
5
],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [
8
],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"3Guofeng3_v32Light.safetensors"
]
}
],
"links": [
[
1,
4,
0,
3,
0,
"MODEL"
],
[
2,
5,
0,
3,
3,
"LATENT"
],
[
3,
4,
1,
6,
0,
"CLIP"
],
[
4,
6,
0,
3,
1,
"CONDITIONING"
],
[
5,
4,
1,
7,
0,
"CLIP"
],
[
6,
7,
0,
3,
2,
"CONDITIONING"
],
[
7,
3,
0,
8,
0,
"LATENT"
],
[
8,
4,
2,
8,
1,
"VAE"
],
[
9,
8,
0,
9,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.8264462809917354,
"offset": [
565.6800000000005,
-43.919999999999995
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,369 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
413,
389
],
"size": {
"0": 425.27801513671875,
"1": 180.6060791015625
},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"text, watermark"
]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
406.3210576748693,
165.8590597338342
],
"size": {
"0": 422.84503173828125,
"1": 164.31304931640625
},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
4
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [
473,
609
],
"size": {
"0": 315,
"1": 106
},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
2
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
512,
512,
1
]
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1451,
189
],
"size": {
"0": 210,
"1": 58
},
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
26,
474
],
"size": {
"0": 315,
"1": 98
},
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
1
],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [
3,
5
],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [
8
],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"3Guofeng3_v32Light.safetensors"
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
863,
186
],
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 1
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
1209,
188
],
"size": {
"0": 210,
"1": 46
},
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
9
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "VAEDecode"
}
}
],
"links": [
[
1,
4,
0,
3,
0,
"MODEL"
],
[
2,
5,
0,
3,
3,
"LATENT"
],
[
3,
4,
1,
6,
0,
"CLIP"
],
[
4,
6,
0,
3,
1,
"CONDITIONING"
],
[
5,
4,
1,
7,
0,
"CLIP"
],
[
8,
4,
2,
8,
1,
"VAE"
],
[
9,
8,
0,
9,
0,
"IMAGE"
]
],
"groups": [
{
"title": "Group",
"bounding": [
489,
95,
140,
80
],
"color": "#3f789e",
"font_size": 24,
"locked": false
}
],
"config": {},
"extra": {
"ds": {
"scale": 1.3513057093104008,
"offset": [
127.07026983402625,
138.77779138162384
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,389 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
413,
389
],
"size": {
"0": 425.27801513671875,
"1": 180.6060791015625
},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
6
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"text, watermark"
]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
406.3210576748693,
165.8590597338342
],
"size": {
"0": 422.84503173828125,
"1": 164.31304931640625
},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
4
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [
473,
609
],
"size": {
"0": 315,
"1": 106
},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
2
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
512,
512,
1
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
863,
186
],
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 1
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
7
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
1209,
188
],
"size": {
"0": 210,
"1": 46
},
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
9
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1451,
189
],
"size": {
"0": 210,
"1": 58
},
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
26,
474
],
"size": {
"0": 315,
"1": 98
},
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
1
],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [
3,
5
],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [
8
],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"3Guofeng3_v32Light.safetensors"
]
}
],
"links": [
[
1,
4,
0,
3,
0,
"MODEL"
],
[
2,
5,
0,
3,
3,
"LATENT"
],
[
3,
4,
1,
6,
0,
"CLIP"
],
[
4,
6,
0,
3,
1,
"CONDITIONING"
],
[
5,
4,
1,
7,
0,
"CLIP"
],
[
6,
7,
0,
3,
2,
"CONDITIONING"
],
[
7,
3,
0,
8,
0,
"LATENT"
],
[
8,
4,
2,
8,
1,
"VAE"
],
[
9,
8,
0,
9,
0,
"IMAGE"
]
],
"groups": [
{
"title": "Group",
"bounding": [
489,
95,
140,
80
],
"color": "#3f789e",
"font_size": 24,
"locked": false
}
],
"config": {},
"extra": {
"ds": {
"scale": 1.11678157794248,
"offset": [
323.87991710157155,
235.75066665118254
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,208 @@
import fs from 'fs'
import { describe, expect, it } from 'vitest'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { defaultGraph } from '@/scripts/defaultGraph'
const WORKFLOW_DIR = 'src/platform/workflow/validation/schemas/__fixtures__'
describe('parseComfyWorkflow', () => {
it('parses valid workflow', async () => {
for await (const file of fs.readdirSync(WORKFLOW_DIR)) {
if (file.endsWith('.json')) {
const data = fs.readFileSync(`${WORKFLOW_DIR}/${file}`, 'utf-8')
await expect(
validateComfyWorkflow(JSON.parse(data))
).resolves.not.toBeNull()
}
}
})
it('workflow.nodes', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes = undefined
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
workflow.nodes = null
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
workflow.nodes = []
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
it('workflow.version', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.version = undefined
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
workflow.version = '1.0.1' // Invalid format (string)
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
// 2018-2024 schema: 0.4
workflow.version = 0.4
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
it('workflow.extra', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.extra = undefined
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
workflow.extra = null
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
workflow.extra = {}
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
workflow.extra = { foo: 'bar' } // Should accept extra fields.
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
it('workflow.nodes.pos', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].pos = [1, 2, 3]
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
workflow.nodes[0].pos = [1, 2]
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
// Should automatically transform the legacy format object to array.
workflow.nodes[0].pos = { '0': 3, '1': 4 }
let validatedWorkflow = await validateComfyWorkflow(workflow)
// @ts-expect-error fixme ts strict error
expect(validatedWorkflow.nodes[0].pos).toEqual([3, 4])
workflow.nodes[0].pos = { 0: 3, 1: 4 }
validatedWorkflow = await validateComfyWorkflow(workflow)
// @ts-expect-error fixme ts strict error
expect(validatedWorkflow.nodes[0].pos).toEqual([3, 4])
// Should accept the legacy bugged format object.
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/710
workflow.nodes[0].pos = {
'0': 600,
'1': 340,
'2': 0,
'3': 0,
'4': 0,
'5': 0,
'6': 0,
'7': 0,
'8': 0,
'9': 0
}
validatedWorkflow = await validateComfyWorkflow(workflow)
// @ts-expect-error fixme ts strict error
expect(validatedWorkflow.nodes[0].pos).toEqual([600, 340])
})
it('workflow.nodes.widget_values', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].widgets_values = ['foo', 'bar']
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
workflow.nodes[0].widgets_values = 'foo'
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
workflow.nodes[0].widgets_values = undefined
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
// The object format of widgets_values is used by VHS nodes to perform
// dynamic widgets display.
workflow.nodes[0].widgets_values = { foo: 'bar' }
const validatedWorkflow = await validateComfyWorkflow(workflow)
// @ts-expect-error fixme ts strict error
expect(validatedWorkflow.nodes[0].widgets_values).toEqual({ foo: 'bar' })
})
it('workflow.links', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.links = [
[
1, // Link id
'100:1', // Node id of source node
'12', // Output slot# of source node
'100:2', // Node id of destination node
15, // Input slot# of destination node
'INT' // Data type
]
]
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
describe('workflow.nodes.properties.aux_id', () => {
const validAuxIds = [
'valid/valid',
'valid-username-with-dash/valid_github-repo-name-with-underscore'
]
it.each(validAuxIds)('valid aux_id: %s', async (aux_id) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.aux_id = aux_id
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
const invalidAuxIds = [
'invalid spaces in username/repo',
'invalid-chars-name-$/repo',
'github-name/invalid spaces in repo',
'not-both-names-with-slash'
]
it.each(invalidAuxIds)('invalid aux_id: %s', async (aux_id) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.aux_id = aux_id
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
})
})
describe('workflow.nodes.properties.cnr_id', () => {
const validCnrIds = ['valid', 'valid-with-dash', 'valid_with_underscores']
it.each(validCnrIds)('valid cnr_id: %s', async (cnr_id) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.cnr_id = cnr_id
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
const invalidCnrIds = ['invalid cnr-id', 'invalid^cnr-id', 'invalid cnr id']
it.each(invalidCnrIds)('invalid cnr_id: %s', async (cnr_id) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.cnr_id = cnr_id
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
})
})
describe('workflow.nodes.properties.ver', () => {
const validVersionStrings = [
// Semver
'0.1.0',
'0.1.0-alpha',
'0.1.0-alpha.1',
'1.3.321',
// Git hash
'080e6d4af809a46852d1c4b7ed85f06e8a3a72be',
// Special case
'unknown',
// Git describe
'v0.3.9-7-g1419dee',
'v0.3.9-7-g1419dee-dirty'
]
it.each(validVersionStrings)('valid version: %s', async (ver) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.ver = ver
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
const invalidVersionStrings = [
// Semver
'0.1-alpha',
'0. 1.0',
'0.0.0.0',
// Git hash
'080e6d4af809a46852d1c4b7ed85f06e8a3a72be-invalid'
]
it.each(invalidVersionStrings)('invalid version: %s', async (ver) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.ver = ver
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
})
})
})