mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-01 20:17:31 +00:00
Compare commits
1 Commits
codex/crit
...
shihchi/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d67c50578 |
@@ -2,8 +2,10 @@ import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/i18n', () => ({
|
||||
@@ -15,6 +17,53 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}))
|
||||
|
||||
const mockShowErrorsTab = vi.hoisted(() => ({ value: false }))
|
||||
const {
|
||||
mockApp,
|
||||
mockCanvasStore,
|
||||
mockExecutionIdToNodeLocatorId,
|
||||
mockGetExecutionIdByNode,
|
||||
mockGetNodeByExecutionId,
|
||||
mockWorkflowStore
|
||||
} = vi.hoisted(() => ({
|
||||
mockApp: {
|
||||
isGraphReady: true,
|
||||
rootGraph: {}
|
||||
},
|
||||
mockCanvasStore: {
|
||||
currentGraph: undefined as object | undefined
|
||||
},
|
||||
mockExecutionIdToNodeLocatorId: vi.fn(
|
||||
(_rootGraph: unknown, id: string) => id as NodeLocatorId
|
||||
),
|
||||
mockGetExecutionIdByNode: vi.fn(),
|
||||
mockGetNodeByExecutionId: vi.fn(),
|
||||
mockWorkflowStore: {
|
||||
nodeLocatorIdToNodeId: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: mockApp }))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
executionIdToNodeLocatorId: (
|
||||
...args: Parameters<typeof mockExecutionIdToNodeLocatorId>
|
||||
) => mockExecutionIdToNodeLocatorId(...args),
|
||||
forEachNode: vi.fn(),
|
||||
getExecutionIdByNode: (
|
||||
...args: Parameters<typeof mockGetExecutionIdByNode>
|
||||
) => mockGetExecutionIdByNode(...args),
|
||||
getNodeByExecutionId: (
|
||||
...args: Parameters<typeof mockGetNodeByExecutionId>
|
||||
) => mockGetNodeByExecutionId(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
@@ -39,6 +88,22 @@ import { useExecutionErrorStore } from './executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
beforeEach(() => {
|
||||
mockShowErrorsTab.value = false
|
||||
mockApp.isGraphReady = true
|
||||
mockCanvasStore.currentGraph = undefined
|
||||
mockExecutionIdToNodeLocatorId.mockImplementation(
|
||||
(_rootGraph: unknown, id: string) => id as NodeLocatorId
|
||||
)
|
||||
mockGetExecutionIdByNode.mockReset()
|
||||
mockGetNodeByExecutionId.mockReset()
|
||||
mockWorkflowStore.nodeLocatorIdToNodeId.mockReset()
|
||||
mockWorkflowStore.nodeLocatorIdToNodeId.mockImplementation(
|
||||
(locator: NodeLocatorId) =>
|
||||
toNodeId(String(locator).split(':').at(-1) ?? locator)
|
||||
)
|
||||
})
|
||||
|
||||
describe('executionErrorStore — node error operations', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -144,6 +209,31 @@ describe('executionErrorStore — node error operations', () => {
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does nothing when the requested slot has no errors', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'123': {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Max exceeded',
|
||||
details: '',
|
||||
extra_info: { input_name: 'otherSlot' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
store.clearSimpleNodeErrors(
|
||||
createNodeExecutionId([toNodeId(123)]),
|
||||
'testSlot'
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves complex errors when slot has both simple and complex errors', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
@@ -388,6 +478,358 @@ describe('executionErrorStore — node error operations', () => {
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps numeric range errors when no range options prove them valid', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'123': {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: '...',
|
||||
details: '',
|
||||
extra_info: { input_name: 'testWidget' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([toNodeId(123)]),
|
||||
'testWidget',
|
||||
'testWidget',
|
||||
15
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears simple widget errors when the numeric value has no node error entry', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'999': {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: '...',
|
||||
details: '',
|
||||
extra_info: { input_name: 'testWidget' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([toNodeId(123)]),
|
||||
'testWidget',
|
||||
'testWidget',
|
||||
15,
|
||||
{ max: 10 }
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors?.['999'].errors).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startup clearing', () => {
|
||||
it('clears execution-start errors and closes the overlay when node errors are empty', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastExecutionError = fromAny({ node_id: '1' })
|
||||
store.lastPromptError = fromAny({ message: 'prompt failed' })
|
||||
store.lastNodeErrors = {}
|
||||
store.showErrorOverlay()
|
||||
|
||||
store.clearExecutionStartErrors()
|
||||
|
||||
expect(store.lastExecutionError).toBeNull()
|
||||
expect(store.lastPromptError).toBeNull()
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the overlay open when node errors remain after execution start', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastExecutionError = fromAny({ node_id: '1' })
|
||||
store.lastPromptError = fromAny({ message: 'prompt failed' })
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: 'x' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'Test'
|
||||
}
|
||||
}
|
||||
store.showErrorOverlay()
|
||||
|
||||
store.clearExecutionStartErrors()
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('executionErrorStore derived graph state', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('derives execution error node ids through locator mapping', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
mockExecutionIdToNodeLocatorId.mockReturnValue(
|
||||
fromAny<NodeLocatorId, string>('graph:7')
|
||||
)
|
||||
store.lastExecutionError = fromAny({ node_id: '7' })
|
||||
|
||||
expect(store.lastExecutionErrorNodeId).toBe(toNodeId(7))
|
||||
})
|
||||
|
||||
it('returns null when there is no execution error locator', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastExecutionError = fromAny({ node_id: '7' })
|
||||
mockExecutionIdToNodeLocatorId.mockReturnValue(
|
||||
fromAny<NodeLocatorId, undefined>(undefined)
|
||||
)
|
||||
|
||||
expect(store.lastExecutionErrorNodeId).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when there is no execution error', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
|
||||
expect(store.lastExecutionErrorNodeId).toBeNull()
|
||||
})
|
||||
|
||||
it('combines prompt, node, execution, and missing-node error counts', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
store.lastPromptError = fromAny({ message: 'prompt failed' })
|
||||
store.lastExecutionError = fromAny({ node_id: null })
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: 'x' }
|
||||
},
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too large',
|
||||
details: '',
|
||||
extra_info: { input_name: 'y' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'Test'
|
||||
}
|
||||
}
|
||||
missingNodesStore.setMissingNodeTypes(
|
||||
fromAny<MissingNodeType[], unknown>([{ type: 'MissingNode', hint: '' }])
|
||||
)
|
||||
|
||||
expect(store.hasPromptError).toBe(true)
|
||||
expect(store.hasNodeError).toBe(true)
|
||||
expect(store.hasExecutionError).toBe(true)
|
||||
expect(store.hasAnyError).toBe(true)
|
||||
expect(store.allErrorExecutionIds).toEqual(['1'])
|
||||
expect(store.totalErrorCount).toBe(5)
|
||||
})
|
||||
|
||||
it('reports empty derived state when there are no errors', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
|
||||
expect(store.hasNodeError).toBe(false)
|
||||
expect(store.allErrorExecutionIds).toEqual([])
|
||||
expect(store.totalErrorCount).toBe(0)
|
||||
})
|
||||
|
||||
it('includes defined execution node ids in the error id list', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastExecutionError = fromAny({ node_id: '2' })
|
||||
|
||||
expect(store.allErrorExecutionIds).toEqual(['2'])
|
||||
})
|
||||
|
||||
it('excludes undefined execution node ids from the error id list', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastExecutionError = fromAny({ node_id: undefined })
|
||||
|
||||
expect(store.allErrorExecutionIds).toEqual([])
|
||||
})
|
||||
|
||||
it('collects active graph node ids for validation and execution errors', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const activeGraph = {}
|
||||
mockCanvasStore.currentGraph = activeGraph
|
||||
mockGetNodeByExecutionId.mockImplementation((_rootGraph, id: string) => ({
|
||||
id: toNodeId(id),
|
||||
graph: activeGraph
|
||||
}))
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: 'x' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'Test'
|
||||
}
|
||||
}
|
||||
store.lastExecutionError = fromAny({ node_id: '2' })
|
||||
|
||||
expect([...store.activeGraphErrorNodeIds].sort()).toEqual(['1', '2'])
|
||||
})
|
||||
|
||||
it('falls back to the root graph when there is no current canvas graph', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
mockCanvasStore.currentGraph = undefined
|
||||
mockGetNodeByExecutionId.mockReturnValue({
|
||||
id: toNodeId(1),
|
||||
graph: mockApp.rootGraph
|
||||
})
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: 'x' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'Test'
|
||||
}
|
||||
}
|
||||
|
||||
expect([...store.activeGraphErrorNodeIds]).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('ignores graph errors outside the active graph', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const activeGraph = {}
|
||||
mockCanvasStore.currentGraph = activeGraph
|
||||
mockGetNodeByExecutionId.mockReturnValue({
|
||||
id: toNodeId(1),
|
||||
graph: {}
|
||||
})
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: 'x' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'Test'
|
||||
}
|
||||
}
|
||||
store.lastExecutionError = fromAny({ node_id: '1' })
|
||||
|
||||
expect(store.activeGraphErrorNodeIds.size).toBe(0)
|
||||
})
|
||||
|
||||
it('returns no active graph node ids before the graph is ready', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
mockApp.isGraphReady = false
|
||||
store.lastExecutionError = fromAny({ node_id: '2' })
|
||||
|
||||
expect(store.activeGraphErrorNodeIds.size).toBe(0)
|
||||
})
|
||||
|
||||
it('maps node errors by locator and checks slots', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const nodeError = {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: 'x' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'Test'
|
||||
}
|
||||
mockExecutionIdToNodeLocatorId.mockImplementation((_rootGraph, id) =>
|
||||
id === 'missing'
|
||||
? fromAny<NodeLocatorId, undefined>(undefined)
|
||||
: fromAny<NodeLocatorId, string>(`locator:${id}`)
|
||||
)
|
||||
store.lastNodeErrors = {
|
||||
'1': nodeError,
|
||||
missing: nodeError
|
||||
}
|
||||
|
||||
const locator = fromAny<NodeLocatorId, string>('locator:1')
|
||||
expect(store.getNodeErrors(locator)).toEqual(nodeError)
|
||||
expect(store.slotHasError(locator, 'x')).toBe(true)
|
||||
expect(store.slotHasError(locator, 'y')).toBe(false)
|
||||
expect(
|
||||
store.getNodeErrors(fromAny<NodeLocatorId, string>('locator:missing'))
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns no slot error when there are no node errors', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
|
||||
expect(
|
||||
store.slotHasError(fromAny<NodeLocatorId, string>('locator:1'), 'x')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('detects container nodes with internal errors', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const node = fromAny<LGraphNode, unknown>({})
|
||||
mockGetExecutionIdByNode.mockReturnValueOnce(undefined)
|
||||
|
||||
expect(store.isContainerWithInternalError(node)).toBe(false)
|
||||
|
||||
store.lastNodeErrors = {
|
||||
'1:2': {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: 'x' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'Test'
|
||||
}
|
||||
}
|
||||
mockGetExecutionIdByNode.mockReturnValue(
|
||||
createNodeExecutionId([toNodeId(1)])
|
||||
)
|
||||
|
||||
expect(store.isContainerWithInternalError(node)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not report container errors before the graph is ready', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
mockApp.isGraphReady = false
|
||||
|
||||
expect(
|
||||
store.isContainerWithInternalError(fromAny<LGraphNode, unknown>({}))
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -457,6 +899,23 @@ describe('surfaceMissingModels — silent option', () => {
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT open error overlay when the setting is disabled', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
mockShowErrorsTab.value = false
|
||||
store.surfaceMissingModels([
|
||||
fromAny({
|
||||
name: 'model.safetensors',
|
||||
nodeId: toNodeId('1'),
|
||||
nodeType: 'Loader',
|
||||
widgetName: 'ckpt',
|
||||
isMissing: true,
|
||||
isAssetSupported: false
|
||||
})
|
||||
])
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('surfaceMissingMedia — silent option', () => {
|
||||
@@ -525,6 +984,23 @@ describe('surfaceMissingMedia — silent option', () => {
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT open error overlay when the setting is disabled', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
mockShowErrorsTab.value = false
|
||||
store.surfaceMissingMedia([
|
||||
fromAny({
|
||||
name: 'photo.png',
|
||||
nodeId: toNodeId('1'),
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
isMissing: true
|
||||
})
|
||||
])
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearAllErrors', () => {
|
||||
|
||||
120
src/stores/executionInterrupt.test.ts
Normal file
120
src/stores/executionInterrupt.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
const { handlers, openSet } = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
|
||||
openSet: new Set<unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
|
||||
handlers[name] = fn
|
||||
},
|
||||
removeEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
isOpen: (workflow: unknown) => openSet.has(workflow),
|
||||
openWorkflows: [],
|
||||
nodeLocatorIdToNodeExecutionId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => ({
|
||||
clearExecutionStartErrors: () => {},
|
||||
clearPromptError: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
|
||||
|
||||
vi.mock('@/utils/appMode', () => ({
|
||||
getWorkflowMode: () => 'workflow',
|
||||
isAppModeValue: () => false
|
||||
}))
|
||||
|
||||
function workflow(path: string): ComfyWorkflow {
|
||||
return { path } as unknown as ComfyWorkflow
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
return store
|
||||
}
|
||||
|
||||
function promptOutput(): ComfyApiWorkflow {
|
||||
return {}
|
||||
}
|
||||
|
||||
function startJob(
|
||||
store: ReturnType<typeof useExecutionStore>,
|
||||
id: string,
|
||||
wf: ComfyWorkflow,
|
||||
nodes: string[] = []
|
||||
) {
|
||||
openSet.add(wf)
|
||||
store.storeJob({ nodes, id, promptOutput: promptOutput(), workflow: wf })
|
||||
handlers['execution_start']?.({ detail: { prompt_id: id } })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
for (const key of Object.keys(handlers)) delete handlers[key]
|
||||
openSet.clear()
|
||||
})
|
||||
|
||||
describe('executionStore interrupt and cached', () => {
|
||||
it('drops the workflow badge and goes idle on interruption', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('a.json')
|
||||
startJob(store, 'job-1', wf)
|
||||
expect(store.getWorkflowStatus(wf)).toBe('running')
|
||||
|
||||
handlers['execution_interrupted']?.({ detail: { prompt_id: 'job-1' } })
|
||||
|
||||
expect(store.getWorkflowStatus(wf)).toBeUndefined()
|
||||
expect(store.isIdle).toBe(true)
|
||||
})
|
||||
|
||||
it('ends the active job when executing resolves to null', () => {
|
||||
const store = setup()
|
||||
startJob(store, 'job-2', workflow('b.json'))
|
||||
expect(store.isIdle).toBe(false)
|
||||
|
||||
handlers['executing']?.({ detail: null })
|
||||
|
||||
expect(store.isIdle).toBe(true)
|
||||
})
|
||||
|
||||
it('marks cached nodes as executed', () => {
|
||||
const store = setup()
|
||||
startJob(store, 'job-3', workflow('c.json'), ['a', 'b', 'c'])
|
||||
expect(store.nodesExecuted).toBe(0)
|
||||
|
||||
handlers['execution_cached']?.({
|
||||
detail: { prompt_id: 'job-3', nodes: ['a', 'b'] }
|
||||
})
|
||||
|
||||
expect(store.nodesExecuted).toBe(2)
|
||||
})
|
||||
})
|
||||
119
src/stores/executionLifecycle.test.ts
Normal file
119
src/stores/executionLifecycle.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
const { handlers } = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (e: { detail: unknown }) => void>
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
|
||||
handlers[name] = fn
|
||||
},
|
||||
removeEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
isOpen: () => false,
|
||||
openWorkflows: [],
|
||||
nodeLocatorIdToNodeExecutionId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => ({
|
||||
clearExecutionStartErrors: () => {},
|
||||
clearPromptError: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
|
||||
|
||||
vi.mock('@/utils/appMode', () => ({
|
||||
getWorkflowMode: () => 'workflow',
|
||||
isAppModeValue: () => false
|
||||
}))
|
||||
|
||||
function setup() {
|
||||
const store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
return store
|
||||
}
|
||||
|
||||
function promptOutput(): ComfyApiWorkflow {
|
||||
return {}
|
||||
}
|
||||
|
||||
function startJob(
|
||||
store: ReturnType<typeof useExecutionStore>,
|
||||
id: string,
|
||||
nodes: string[]
|
||||
) {
|
||||
store.storeJob({
|
||||
nodes,
|
||||
id,
|
||||
promptOutput: promptOutput(),
|
||||
workflow: { path: `${id}.json` } as unknown as ComfyWorkflow
|
||||
})
|
||||
handlers['execution_start']?.({ detail: { prompt_id: id } })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
for (const key of Object.keys(handlers)) delete handlers[key]
|
||||
})
|
||||
|
||||
describe('executionStore execution lifecycle', () => {
|
||||
it('reports zero progress while idle', () => {
|
||||
const store = setup()
|
||||
expect(store.totalNodesToExecute).toBe(0)
|
||||
expect(store.nodesExecuted).toBe(0)
|
||||
expect(store.executionProgress).toBe(0)
|
||||
})
|
||||
|
||||
it('counts the queued nodes once a job starts', () => {
|
||||
const store = setup()
|
||||
startJob(store, 'job-1', ['a', 'b', 'c'])
|
||||
|
||||
expect(store.totalNodesToExecute).toBe(3)
|
||||
expect(store.nodesExecuted).toBe(0)
|
||||
expect(store.executionProgress).toBe(0)
|
||||
})
|
||||
|
||||
it('advances progress as executed events arrive', () => {
|
||||
const store = setup()
|
||||
startJob(store, 'job-1', ['a', 'b', 'c'])
|
||||
|
||||
handlers['executed']?.({ detail: { node: 'a' } })
|
||||
expect(store.nodesExecuted).toBe(1)
|
||||
expect(store.executionProgress).toBeCloseTo(1 / 3)
|
||||
|
||||
handlers['executed']?.({ detail: { node: 'b' } })
|
||||
handlers['executed']?.({ detail: { node: 'c' } })
|
||||
expect(store.nodesExecuted).toBe(3)
|
||||
expect(store.executionProgress).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores executed events when there is no active job', () => {
|
||||
const store = setup()
|
||||
handlers['executed']?.({ detail: { node: 'a' } })
|
||||
expect(store.nodesExecuted).toBe(0)
|
||||
})
|
||||
})
|
||||
128
src/stores/executionNodeProgress.test.ts
Normal file
128
src/stores/executionNodeProgress.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
const { handlers } = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (e: { detail: unknown }) => void>
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
|
||||
handlers[name] = fn
|
||||
},
|
||||
removeEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
isOpen: () => false,
|
||||
openWorkflows: [],
|
||||
nodeLocatorIdToNodeExecutionId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => ({
|
||||
clearExecutionStartErrors: () => {},
|
||||
clearPromptError: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({ revokePreviewsByExecutionId: () => {} })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
|
||||
|
||||
vi.mock('@/utils/appMode', () => ({
|
||||
getWorkflowMode: () => 'workflow',
|
||||
isAppModeValue: () => false
|
||||
}))
|
||||
|
||||
function progressState(
|
||||
jobId: string,
|
||||
nodes: Record<string, Partial<NodeProgressState>>
|
||||
) {
|
||||
handlers['progress_state']?.({ detail: { prompt_id: jobId, nodes } })
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
return store
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
for (const key of Object.keys(handlers)) delete handlers[key]
|
||||
})
|
||||
|
||||
describe('executionStore node progress', () => {
|
||||
it('is idle until an execution starts', () => {
|
||||
const store = setup()
|
||||
expect(store.isIdle).toBe(true)
|
||||
|
||||
handlers['execution_start']?.({ detail: { prompt_id: 'job-1' } })
|
||||
expect(store.isIdle).toBe(false)
|
||||
})
|
||||
|
||||
it('derives the running node ids from a progress_state event', () => {
|
||||
const store = setup()
|
||||
|
||||
progressState('job-1', {
|
||||
n1: { state: 'running', value: 1, max: 4 },
|
||||
n2: { state: 'finished' },
|
||||
n3: { state: 'pending' }
|
||||
})
|
||||
|
||||
expect(store.executingNodeIds).toEqual(['n1'])
|
||||
expect(store.executingNodeId).toBe('n1')
|
||||
})
|
||||
|
||||
it('exposes fractional progress for the executing node', () => {
|
||||
const store = setup()
|
||||
|
||||
progressState('job-1', {
|
||||
n1: { state: 'running', value: 1, max: 4 }
|
||||
})
|
||||
|
||||
expect(store.executingNodeProgress).toBe(0.25)
|
||||
})
|
||||
|
||||
it('reports no executing node when none are running', () => {
|
||||
const store = setup()
|
||||
|
||||
progressState('job-1', {
|
||||
n1: { state: 'finished' },
|
||||
n2: { state: 'pending' }
|
||||
})
|
||||
|
||||
expect(store.executingNodeIds).toEqual([])
|
||||
expect(store.executingNodeId).toBeNull()
|
||||
})
|
||||
|
||||
it('replaces progress state on each progress_state event', () => {
|
||||
const store = setup()
|
||||
|
||||
progressState('job-1', { n1: { state: 'running', value: 1, max: 4 } })
|
||||
expect(store.executingNodeId).toBe('n1')
|
||||
|
||||
progressState('job-1', { n2: { state: 'running', value: 2, max: 2 } })
|
||||
expect(store.executingNodeIds).toEqual(['n2'])
|
||||
})
|
||||
})
|
||||
173
src/stores/executionRunningState.test.ts
Normal file
173
src/stores/executionRunningState.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import type { classifyCloudValidationError } from '@/utils/executionErrorUtil'
|
||||
|
||||
type CloudValidationResult = ReturnType<typeof classifyCloudValidationError>
|
||||
|
||||
const { handlers, errorStore, activeWorkflow, dist, classifyCloud } =
|
||||
vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
|
||||
errorStore: {
|
||||
clearExecutionStartErrors: () => {},
|
||||
clearPromptError: () => {}
|
||||
} as Record<string, unknown>,
|
||||
activeWorkflow: { value: null as { path: string } | null },
|
||||
dist: { isCloud: false },
|
||||
classifyCloud: vi.fn<(_: string) => CloudValidationResult>(() => null)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
|
||||
handlers[name] = fn
|
||||
},
|
||||
removeEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
isOpen: () => true,
|
||||
openWorkflows: [],
|
||||
nodeLocatorIdToNodeExecutionId: () => null,
|
||||
get activeWorkflow() {
|
||||
return activeWorkflow.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: undefined })
|
||||
}))
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => errorStore
|
||||
}))
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({ revokePreviewsByExecutionId: () => {} })
|
||||
}))
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
|
||||
}))
|
||||
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
|
||||
vi.mock('@/utils/appMode', () => ({
|
||||
getWorkflowMode: () => 'workflow',
|
||||
isAppModeValue: () => false
|
||||
}))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return dist.isCloud
|
||||
}
|
||||
}))
|
||||
vi.mock('@/platform/errorCatalog/accountPreconditionRouting', () => ({
|
||||
resolveAccountPrecondition: () => null
|
||||
}))
|
||||
vi.mock('@/utils/executionErrorUtil', () => ({
|
||||
classifyCloudValidationError: classifyCloud
|
||||
}))
|
||||
|
||||
function setup() {
|
||||
const store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
return store
|
||||
}
|
||||
|
||||
function workflow(path: string): ComfyWorkflow {
|
||||
return { path } as unknown as ComfyWorkflow
|
||||
}
|
||||
|
||||
function promptOutput(): ComfyApiWorkflow {
|
||||
return {}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
for (const key of Object.keys(handlers)) delete handlers[key]
|
||||
activeWorkflow.value = null
|
||||
dist.isCloud = false
|
||||
classifyCloud.mockReturnValue(null)
|
||||
for (const k of ['lastPromptError', 'lastNodeErrors', 'lastExecutionError'])
|
||||
delete errorStore[k]
|
||||
})
|
||||
|
||||
describe('executionStore running state and error edges', () => {
|
||||
it('lists jobs with a running node and counts running workflows', () => {
|
||||
const store = setup()
|
||||
handlers['progress_state']?.({
|
||||
detail: {
|
||||
prompt_id: 'job-1',
|
||||
nodes: { n1: { state: 'running', value: 1, max: 2 } }
|
||||
}
|
||||
})
|
||||
|
||||
expect(store.runningJobIds).toEqual(['job-1'])
|
||||
expect(store.runningWorkflowCount).toBe(1)
|
||||
})
|
||||
|
||||
it('does not report the active workflow as running when the path differs', () => {
|
||||
const store = setup()
|
||||
expect(store.isActiveWorkflowRunning).toBe(false)
|
||||
|
||||
const wf = workflow('w.json')
|
||||
activeWorkflow.value = { path: 'other.json' }
|
||||
store.storeJob({
|
||||
nodes: [],
|
||||
id: 'job-2',
|
||||
promptOutput: promptOutput(),
|
||||
workflow: wf
|
||||
})
|
||||
handlers['execution_start']?.({ detail: { prompt_id: 'job-2' } })
|
||||
|
||||
expect(store.isActiveWorkflowRunning).toBe(false)
|
||||
})
|
||||
|
||||
it('reports the active workflow as running when job, path and session agree', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('w.json')
|
||||
activeWorkflow.value = { path: 'w.json' }
|
||||
store.storeJob({
|
||||
nodes: [],
|
||||
id: 'job-2',
|
||||
promptOutput: promptOutput(),
|
||||
workflow: wf
|
||||
})
|
||||
handlers['execution_start']?.({ detail: { prompt_id: 'job-2' } })
|
||||
|
||||
expect(store.isActiveWorkflowRunning).toBe(true)
|
||||
})
|
||||
|
||||
it('formats a service-level error message from the exception message alone', () => {
|
||||
setup()
|
||||
handlers['execution_error']?.({
|
||||
detail: { prompt_id: 'job-3', exception_message: 'Job has stagnated' }
|
||||
})
|
||||
|
||||
expect(errorStore.lastPromptError).toEqual({
|
||||
type: 'error',
|
||||
message: 'Job has stagnated',
|
||||
details: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('stores a classified cloud prompt error on the prompt-error branch', () => {
|
||||
dist.isCloud = true
|
||||
classifyCloud.mockReturnValue({
|
||||
kind: 'promptError',
|
||||
promptError: { type: 'validation', message: 'bad input', details: '' }
|
||||
})
|
||||
setup()
|
||||
|
||||
handlers['execution_error']?.({
|
||||
detail: { prompt_id: 'job-4', exception_message: '{}' }
|
||||
})
|
||||
|
||||
expect(errorStore.lastPromptError).toEqual({
|
||||
type: 'validation',
|
||||
message: 'bad input',
|
||||
details: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
153
src/stores/executionWorkflowStatus.test.ts
Normal file
153
src/stores/executionWorkflowStatus.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
const { handlers, openSet } = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
|
||||
openSet: new Set<unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
|
||||
handlers[name] = fn
|
||||
},
|
||||
removeEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
isOpen: (workflow: unknown) => openSet.has(workflow),
|
||||
openWorkflows: [],
|
||||
nodeLocatorIdToNodeExecutionId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => ({
|
||||
clearExecutionStartErrors: () => {},
|
||||
clearPromptError: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
|
||||
|
||||
vi.mock('@/utils/appMode', () => ({
|
||||
getWorkflowMode: () => 'workflow',
|
||||
isAppModeValue: () => false
|
||||
}))
|
||||
|
||||
function workflow(path: string): ComfyWorkflow {
|
||||
return { path } as unknown as ComfyWorkflow
|
||||
}
|
||||
|
||||
function promptOutput(): ComfyApiWorkflow {
|
||||
return {}
|
||||
}
|
||||
|
||||
function storeJob(
|
||||
store: ReturnType<typeof useExecutionStore>,
|
||||
id: string,
|
||||
wf: ComfyWorkflow
|
||||
) {
|
||||
store.storeJob({ nodes: [], id, promptOutput: promptOutput(), workflow: wf })
|
||||
}
|
||||
|
||||
function fire(event: string, jobId: string) {
|
||||
handlers[event]?.({ detail: { prompt_id: jobId } })
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
return store
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
for (const key of Object.keys(handlers)) delete handlers[key]
|
||||
openSet.clear()
|
||||
})
|
||||
|
||||
describe('executionStore workflow status', () => {
|
||||
it('marks an open workflow running on execution_start and completed on success', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('a.json')
|
||||
openSet.add(wf)
|
||||
storeJob(store, 'job-1', wf)
|
||||
|
||||
fire('execution_start', 'job-1')
|
||||
expect(store.getWorkflowStatus(wf)).toBe('running')
|
||||
|
||||
fire('execution_success', 'job-1')
|
||||
expect(store.getWorkflowStatus(wf)).toBe('completed')
|
||||
})
|
||||
|
||||
it('buffers a status that arrives before the job is attached, then flushes on storeJob', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('b.json')
|
||||
openSet.add(wf)
|
||||
|
||||
fire('execution_start', 'job-2')
|
||||
expect(store.getWorkflowStatus(wf)).toBeUndefined()
|
||||
|
||||
storeJob(store, 'job-2', wf)
|
||||
expect(store.getWorkflowStatus(wf)).toBe('running')
|
||||
})
|
||||
|
||||
it('does not apply status to a workflow that is not open', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('c.json')
|
||||
storeJob(store, 'job-3', wf)
|
||||
|
||||
fire('execution_start', 'job-3')
|
||||
expect(store.getWorkflowStatus(wf)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears a workflow status', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('d.json')
|
||||
openSet.add(wf)
|
||||
storeJob(store, 'job-4', wf)
|
||||
fire('execution_start', 'job-4')
|
||||
expect(store.getWorkflowStatus(wf)).toBe('running')
|
||||
|
||||
store.clearWorkflowStatus(wf)
|
||||
expect(store.getWorkflowStatus(wf)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not let a late buffered running overwrite a terminal status', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('e.json')
|
||||
openSet.add(wf)
|
||||
|
||||
storeJob(store, 'job-5', wf)
|
||||
fire('execution_success', 'job-5')
|
||||
expect(store.getWorkflowStatus(wf)).toBe('completed')
|
||||
|
||||
fire('execution_start', 'job-6')
|
||||
storeJob(store, 'job-6', wf)
|
||||
expect(store.getWorkflowStatus(wf)).toBe('completed')
|
||||
})
|
||||
|
||||
it('returns undefined for a null or unknown workflow', () => {
|
||||
const store = setup()
|
||||
expect(store.getWorkflowStatus(null)).toBeUndefined()
|
||||
expect(store.getWorkflowStatus(workflow('unknown.json'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
139
src/stores/queueResultItem.test.ts
Normal file
139
src/stores/queueResultItem.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (path: string) => `http://localhost:8188${path}`,
|
||||
addEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
// Importing ResultItemImpl transitively loads @/scripts/app, whose module-level
|
||||
// ComfyApp singleton wires real listeners. Stub it; ResultItemImpl needs none of it.
|
||||
vi.mock('@/scripts/app', () => ({ app: {} }))
|
||||
|
||||
// Keep preview-url assertions deterministic: don't append cloud params.
|
||||
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
|
||||
appendCloudResParam: () => {}
|
||||
}))
|
||||
|
||||
interface ItemOverrides {
|
||||
filename?: string
|
||||
mediaType?: string
|
||||
format?: string
|
||||
frame_rate?: number
|
||||
}
|
||||
|
||||
function item(over: ItemOverrides = {}) {
|
||||
return new ResultItemImpl({
|
||||
filename: over.filename ?? 'out.png',
|
||||
subfolder: 'sub',
|
||||
type: 'output',
|
||||
nodeId: '1' as SerializedNodeId,
|
||||
mediaType: over.mediaType ?? 'images',
|
||||
format: over.format,
|
||||
frame_rate: over.frame_rate
|
||||
})
|
||||
}
|
||||
|
||||
describe('ResultItemImpl', () => {
|
||||
it('builds view url params and omits absent vhs fields', () => {
|
||||
const params = item({ filename: 'a.png' }).urlParams
|
||||
expect(params.get('filename')).toBe('a.png')
|
||||
expect(params.get('type')).toBe('output')
|
||||
expect(params.get('subfolder')).toBe('sub')
|
||||
expect(params.has('format')).toBe(false)
|
||||
expect(params.has('frame_rate')).toBe(false)
|
||||
})
|
||||
|
||||
it('includes vhs format and frame_rate params when present', () => {
|
||||
const params = item({ format: 'video/h264-mp4', frame_rate: 24 }).urlParams
|
||||
expect(params.get('format')).toBe('video/h264-mp4')
|
||||
expect(params.get('frame_rate')).toBe('24')
|
||||
})
|
||||
|
||||
it('returns an empty url for a nameless item and a view url otherwise', () => {
|
||||
expect(item({ filename: '' }).url).toBe('')
|
||||
expect(item({ filename: 'a.png' }).url).toContain('/view?')
|
||||
})
|
||||
|
||||
it('routes image preview urls through /view', () => {
|
||||
expect(
|
||||
item({ filename: 'a.png', mediaType: 'images' }).previewUrl
|
||||
).toContain('/view?')
|
||||
})
|
||||
|
||||
it('exposes the vhs advanced preview endpoint', () => {
|
||||
expect(item().vhsAdvancedPreviewUrl).toContain('/viewvideo?')
|
||||
})
|
||||
|
||||
it('maps html video mime types by suffix and vhs format', () => {
|
||||
expect(item({ filename: 'a.webm' }).htmlVideoType).toBe('video/webm')
|
||||
expect(item({ filename: 'a.mp4' }).htmlVideoType).toBe('video/mp4')
|
||||
expect(item({ filename: 'a.mov' }).htmlVideoType).toBe('video/quicktime')
|
||||
expect(
|
||||
item({ filename: 'a.bin', format: 'video/mp4', frame_rate: 24 })
|
||||
.htmlVideoType
|
||||
).toBe('video/mp4')
|
||||
expect(item({ filename: 'a.txt' }).htmlVideoType).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps html audio mime types by suffix', () => {
|
||||
expect(item({ filename: 'a.mp3' }).htmlAudioType).toBe('audio/mpeg')
|
||||
expect(item({ filename: 'a.wav' }).htmlAudioType).toBe('audio/wav')
|
||||
expect(item({ filename: 'a.ogg' }).htmlAudioType).toBe('audio/ogg')
|
||||
expect(item({ filename: 'a.flac' }).htmlAudioType).toBe('audio/flac')
|
||||
expect(item({ filename: 'a.png' }).htmlAudioType).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats vhs format as such only with both format and frame_rate', () => {
|
||||
expect(item({ format: 'video/mp4', frame_rate: 24 }).isVhsFormat).toBe(true)
|
||||
expect(item({ format: 'video/mp4' }).isVhsFormat).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies video by suffix and by media type', () => {
|
||||
expect(item({ filename: 'a.webm' }).isVideo).toBe(true)
|
||||
expect(item({ filename: 'a.bin', mediaType: 'video' }).isVideo).toBe(true)
|
||||
expect(item({ filename: 'a.png', mediaType: 'video' }).isVideo).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies image only when not contradicted by a media suffix', () => {
|
||||
expect(item({ filename: 'a.png', mediaType: 'images' }).isImage).toBe(true)
|
||||
expect(item({ filename: 'a.webm', mediaType: 'images' }).isImage).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('classifies audio by suffix and by media type', () => {
|
||||
expect(item({ filename: 'a.mp3' }).isAudio).toBe(true)
|
||||
expect(item({ filename: 'a.bin', mediaType: 'audio' }).isAudio).toBe(true)
|
||||
expect(item({ filename: 'a.png', mediaType: 'audio' }).isAudio).toBe(false)
|
||||
})
|
||||
|
||||
it('reports text and preview support', () => {
|
||||
const text = item({ filename: 'a.txt', mediaType: 'text' })
|
||||
expect(text.isText).toBe(true)
|
||||
expect(text.supportsPreview).toBe(true)
|
||||
expect(item({ filename: 'a.png' }).supportsPreview).toBe(true)
|
||||
expect(
|
||||
item({ filename: 'a.bin', mediaType: 'binary' }).supportsPreview
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('filters previewable outputs and finds an item by url', () => {
|
||||
const png = item({ filename: 'a.png' })
|
||||
const mp3 = item({ filename: 'b.mp3', mediaType: 'audio' })
|
||||
const bin = item({ filename: 'a.bin', mediaType: 'binary' })
|
||||
expect(ResultItemImpl.filterPreviewable([png, mp3, bin])).toEqual([
|
||||
png,
|
||||
mp3
|
||||
])
|
||||
|
||||
expect(ResultItemImpl.findByUrl([png, mp3, bin], png.url)).toBe(0)
|
||||
expect(ResultItemImpl.findByUrl([png, mp3, bin], mp3.url)).toBe(1)
|
||||
expect(ResultItemImpl.findByUrl([png, mp3, bin], 'no-match')).toBe(0)
|
||||
expect(ResultItemImpl.findByUrl([png, mp3, bin])).toBe(0)
|
||||
})
|
||||
})
|
||||
210
src/stores/queueTaskItem.test.ts
Normal file
210
src/stores/queueTaskItem.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ResultItemType, TaskOutput } from '@/schemas/apiSchema'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (path: string) => `http://localhost:8188${path}`,
|
||||
addEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: {} }))
|
||||
|
||||
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
|
||||
appendCloudResParam: () => {}
|
||||
}))
|
||||
|
||||
const { parseTaskOutput } = vi.hoisted(() => ({ parseTaskOutput: vi.fn() }))
|
||||
vi.mock('@/stores/resultItemParsing', () => ({ parseTaskOutput }))
|
||||
|
||||
beforeEach(() => {
|
||||
parseTaskOutput.mockClear()
|
||||
})
|
||||
|
||||
type JobStatus =
|
||||
| 'in_progress'
|
||||
| 'pending'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
|
||||
function executionError(
|
||||
overrides: Partial<NonNullable<JobListItem['execution_error']>> = {}
|
||||
): NonNullable<JobListItem['execution_error']> {
|
||||
return {
|
||||
node_id: '1',
|
||||
node_type: 'KSampler',
|
||||
exception_message: 'boom',
|
||||
exception_type: 'Error',
|
||||
traceback: [],
|
||||
current_inputs: {},
|
||||
current_outputs: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function job(over: Partial<JobListItem> = {}): JobListItem {
|
||||
return {
|
||||
id: 'job-1',
|
||||
status: 'completed',
|
||||
create_time: 1000,
|
||||
priority: 0,
|
||||
...over
|
||||
}
|
||||
}
|
||||
|
||||
function result(filename: string, type: ResultItemType = 'output') {
|
||||
return new ResultItemImpl({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type,
|
||||
nodeId: '1' as SerializedNodeId,
|
||||
mediaType: 'images'
|
||||
})
|
||||
}
|
||||
|
||||
describe('TaskItemImpl', () => {
|
||||
it('maps job status to taskType and apiTaskType', () => {
|
||||
expect(new TaskItemImpl(job({ status: 'in_progress' })).taskType).toBe(
|
||||
'Running'
|
||||
)
|
||||
expect(new TaskItemImpl(job({ status: 'pending' })).taskType).toBe(
|
||||
'Pending'
|
||||
)
|
||||
expect(new TaskItemImpl(job({ status: 'completed' })).taskType).toBe(
|
||||
'History'
|
||||
)
|
||||
|
||||
expect(new TaskItemImpl(job({ status: 'pending' })).apiTaskType).toBe(
|
||||
'queue'
|
||||
)
|
||||
expect(new TaskItemImpl(job({ status: 'completed' })).apiTaskType).toBe(
|
||||
'history'
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes displayStatus for every backend status', () => {
|
||||
const statuses: [JobStatus, string][] = [
|
||||
['in_progress', 'Running'],
|
||||
['pending', 'Pending'],
|
||||
['completed', 'Completed'],
|
||||
['failed', 'Failed'],
|
||||
['cancelled', 'Cancelled']
|
||||
]
|
||||
for (const [status, display] of statuses) {
|
||||
expect(new TaskItemImpl(job({ status })).displayStatus).toBe(display)
|
||||
}
|
||||
})
|
||||
|
||||
it('derives history/running flags and a status-qualified key', () => {
|
||||
const running = new TaskItemImpl(job({ id: 'a', status: 'in_progress' }))
|
||||
expect(running.isRunning).toBe(true)
|
||||
expect(running.isHistory).toBe(false)
|
||||
expect(running.key).toBe('aRunning')
|
||||
|
||||
expect(new TaskItemImpl(job({ status: 'completed' })).isHistory).toBe(true)
|
||||
})
|
||||
|
||||
it('uses explicitly provided flat outputs', () => {
|
||||
const outputs = [result('a.png')]
|
||||
const task = new TaskItemImpl(job(), undefined, outputs)
|
||||
expect(task.flatOutputs).toBe(outputs)
|
||||
})
|
||||
|
||||
it('parses outputs lazily when flat outputs are not supplied', () => {
|
||||
const parsed = [result('p.png')]
|
||||
parseTaskOutput.mockReturnValueOnce(parsed)
|
||||
const outputs: TaskOutput = { '1': { images: [] } }
|
||||
const task = new TaskItemImpl(job(), outputs)
|
||||
expect(parseTaskOutput).toHaveBeenCalled()
|
||||
expect(task.flatOutputs).toBe(parsed)
|
||||
})
|
||||
|
||||
it('synthesizes outputs from preview_output when none are provided', () => {
|
||||
parseTaskOutput.mockReturnValueOnce([])
|
||||
const preview = { nodeId: '5', mediaType: 'images', filename: 'prev.png' }
|
||||
new TaskItemImpl(job({ preview_output: preview }))
|
||||
expect(parseTaskOutput).toHaveBeenCalledWith({
|
||||
'5': { images: [preview] }
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the last saved output over temp previews for previewOutput', () => {
|
||||
const temp = result('temp.png', 'temp')
|
||||
const saved = result('saved.png', 'output')
|
||||
const task = new TaskItemImpl(job(), undefined, [temp, saved])
|
||||
expect(task.previewOutput).toBe(saved)
|
||||
|
||||
const onlyTemp = new TaskItemImpl(job(), undefined, [temp])
|
||||
expect(onlyTemp.previewOutput).toBe(temp)
|
||||
})
|
||||
|
||||
it('reports interrupted only for an interrupt-typed failure', () => {
|
||||
expect(
|
||||
new TaskItemImpl(
|
||||
job({
|
||||
status: 'failed',
|
||||
execution_error: executionError({
|
||||
exception_type: 'InterruptProcessingException'
|
||||
})
|
||||
})
|
||||
).interrupted
|
||||
).toBe(true)
|
||||
expect(
|
||||
new TaskItemImpl(
|
||||
job({
|
||||
status: 'failed',
|
||||
execution_error: executionError({ exception_type: 'Other' })
|
||||
})
|
||||
).interrupted
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces error message and passthrough job fields', () => {
|
||||
const task = new TaskItemImpl(
|
||||
job({
|
||||
status: 'failed',
|
||||
outputs_count: 3,
|
||||
workflow_id: 'wf-9',
|
||||
execution_error: executionError({ exception_message: 'boom' })
|
||||
})
|
||||
)
|
||||
expect(task.errorMessage).toBe('boom')
|
||||
expect(task.outputsCount).toBe(3)
|
||||
expect(task.workflowId).toBe('wf-9')
|
||||
})
|
||||
|
||||
it('computes execution time only when both timestamps exist', () => {
|
||||
expect(
|
||||
new TaskItemImpl(
|
||||
job({ execution_start_time: 1000, execution_end_time: 3000 })
|
||||
).executionTimeInSeconds
|
||||
).toBe(2)
|
||||
expect(
|
||||
new TaskItemImpl(job({ execution_start_time: 1000 })).executionTime
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('flatten returns itself when not completed', () => {
|
||||
const running = new TaskItemImpl(job({ status: 'in_progress' }))
|
||||
expect(running.flatten()).toEqual([running])
|
||||
})
|
||||
|
||||
it('flatten expands a completed task into one task per output', () => {
|
||||
const outputs = [result('a.png'), result('b.png')]
|
||||
const task = new TaskItemImpl(
|
||||
job({ id: 'j', status: 'completed' }),
|
||||
undefined,
|
||||
outputs
|
||||
)
|
||||
|
||||
const flattened = task.flatten()
|
||||
|
||||
expect(flattened).toHaveLength(2)
|
||||
expect(flattened.map((t) => t.jobId)).toEqual(['j-0', 'j-1'])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
|
||||
@@ -154,6 +154,22 @@ describe(parseNodeOutput, () => {
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].filename).toBe('valid.png')
|
||||
})
|
||||
|
||||
it('excludes non-object and invalid-type items', () => {
|
||||
const output = fromAny<NodeExecutionOutput, unknown>({
|
||||
images: [
|
||||
null,
|
||||
'not-an-item',
|
||||
{ filename: 'bad.png', type: 'invalid' },
|
||||
{ filename: 'valid.png', type: 'output' }
|
||||
]
|
||||
})
|
||||
|
||||
const result = parseNodeOutput('1', output)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].filename).toBe('valid.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe(parseTaskOutput, () => {
|
||||
|
||||
Reference in New Issue
Block a user