mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 05:38:26 +00:00
Compare commits
1 Commits
shihchi/co
...
shihchi/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d67c50578 |
@@ -1,26 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
|
||||
describe('actionBarButtonStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('collects action bar buttons from registered extensions', () => {
|
||||
const extensionStore = useExtensionStore()
|
||||
const onClick = vi.fn()
|
||||
extensionStore.registerExtension({
|
||||
name: 'buttons',
|
||||
actionBarButtons: [{ icon: 'icon-[lucide--plus]', onClick }]
|
||||
})
|
||||
extensionStore.registerExtension({ name: 'plain' })
|
||||
|
||||
const store = useActionBarButtonStore()
|
||||
|
||||
expect(store.buttons).toEqual([{ icon: 'icon-[lucide--plus]', onClick }])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -56,13 +56,9 @@ vi.mock('@/utils/litegraphUtil', async (importOriginal) => ({
|
||||
resolveNode: mockResolveNode
|
||||
}))
|
||||
|
||||
const mockCanvas = vi.hoisted(() => ({
|
||||
state: undefined as { readOnly: boolean } | undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: () => ({ state: mockCanvas.state })
|
||||
getCanvas: () => ({ read_only: false })
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -166,7 +162,6 @@ describe('appModeStore', () => {
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
mockSettings.reset()
|
||||
mockCanvas.state = undefined
|
||||
vi.mocked(app.rootGraph).nodes = [{ id: toNodeId(1) } as LGraphNode]
|
||||
workflowStore = useWorkflowStore()
|
||||
store = useAppModeStore()
|
||||
@@ -370,83 +365,6 @@ describe('appModeStore', () => {
|
||||
expect(store.selectedInputs).toEqual([[entityPrompt, 'prompt']])
|
||||
})
|
||||
|
||||
it('keeps canonical entity ids when the node still exists', () => {
|
||||
const node1 = nodeWithWidgets(1, [])
|
||||
vi.mocked(app.rootGraph).nodes = [node1]
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
|
||||
id === toNodeId(1) ? node1 : null
|
||||
)
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [[entityPrompt, 'prompt']]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([[entityPrompt, 'prompt']])
|
||||
})
|
||||
|
||||
it('drops canonical entity ids when their node is gone', () => {
|
||||
vi.mocked(app.rootGraph).nodes = []
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(() => null)
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [[entityPrompt, 'prompt']]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
})
|
||||
|
||||
it('drops locator inputs when the widget does not resolve', () => {
|
||||
const hostLocator = `${rootGraphId}:5`
|
||||
const hostNode = fromAny<LGraphNode, unknown>({
|
||||
id: 5,
|
||||
isSubgraphNode: () => false,
|
||||
widgets: [{ name: 'other' }]
|
||||
})
|
||||
vi.mocked(app.rootGraph).nodes = [hostNode]
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
|
||||
id === toNodeId(5) ? hostNode : null
|
||||
)
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [[hostLocator, 'prompt']]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
})
|
||||
|
||||
it('drops malformed legacy input ids', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.mocked(app.rootGraph).nodes = []
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [[fromAny<SerializedNodeId, unknown>(null), 'prompt']]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('legacy selectedInput tuple'),
|
||||
expect.objectContaining({ storedId: null, widgetName: 'prompt' })
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('drops direct node inputs when the widget is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node1 = nodeWithWidgets(1, [])
|
||||
vi.mocked(app.rootGraph).nodes = [node1]
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
|
||||
id === toNodeId(1) ? node1 : null
|
||||
)
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [[1, 'prompt']]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('drops legacy entries whose widget no longer exists', () => {
|
||||
const node1 = nodeWithWidgets(1, ['prompt'])
|
||||
vi.mocked(app.rootGraph).nodes = [node1]
|
||||
@@ -481,32 +399,6 @@ describe('appModeStore', () => {
|
||||
expect(store.selectedOutputs).toEqual([toNodeId(1)])
|
||||
})
|
||||
|
||||
it('drops malformed output ids on load', () => {
|
||||
store.loadSelections({
|
||||
outputs: [fromAny<SerializedNodeId, unknown>('')]
|
||||
})
|
||||
|
||||
expect(store.selectedOutputs).toEqual([])
|
||||
})
|
||||
|
||||
it('drops legacy subgraph input slots without widget ids', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const hostNode = Object.assign(Object.create(SubgraphNode.prototype), {
|
||||
id: 5,
|
||||
inputs: [{ name: 'Prompt' }]
|
||||
})
|
||||
vi.mocked(app.rootGraph).nodes = [hostNode]
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(() => null)
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [[1, 'prompt']]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reloads selections on configured event', async () => {
|
||||
const node1 = nodeWithWidgets(1, ['seed'])
|
||||
|
||||
@@ -589,7 +481,7 @@ describe('appModeStore', () => {
|
||||
expect(
|
||||
store.pruneLinearData({
|
||||
inputs: [[1, 'seed']],
|
||||
outputs: [toNodeId(1), fromAny<SerializedNodeId, unknown>('')]
|
||||
outputs: [toNodeId(1)]
|
||||
})
|
||||
).toEqual({
|
||||
inputs: [[1, 'seed']],
|
||||
@@ -749,17 +641,6 @@ describe('appModeStore', () => {
|
||||
expect(originalRootGraph.extra.linearData).toEqual(dataBefore)
|
||||
})
|
||||
|
||||
it('does not write while graph loading is in progress', async () => {
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow()
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
await nextTick()
|
||||
|
||||
store.selectedOutputs.push(toNodeId(1))
|
||||
await nextTick()
|
||||
|
||||
expect(app.rootGraph.extra.linearData).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls captureCanvasState when input is selected', async () => {
|
||||
const workflow = createBuilderWorkflow()
|
||||
workflowStore.activeWorkflow = workflow
|
||||
@@ -874,24 +755,6 @@ describe('appModeStore', () => {
|
||||
|
||||
expect(store.selectedInputs).toEqual([[promptEntity, 'prompt']])
|
||||
})
|
||||
|
||||
it('ignores widgets without ids', () => {
|
||||
store.selectedInputs.push(['g:1:prompt' as WidgetId, 'prompt'])
|
||||
|
||||
store.removeSelectedInput(fromAny<IBaseWidget, unknown>({}))
|
||||
|
||||
expect(store.selectedInputs).toEqual([['g:1:prompt', 'prompt']])
|
||||
})
|
||||
|
||||
it('ignores missing input ids', () => {
|
||||
store.selectedInputs.push(['g:1:prompt' as WidgetId, 'prompt'])
|
||||
|
||||
store.removeSelectedInput(
|
||||
fromAny<IBaseWidget, unknown>({ widgetId: 'g:2:prompt' })
|
||||
)
|
||||
|
||||
expect(store.selectedInputs).toEqual([['g:1:prompt', 'prompt']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoEnableVueNodes', () => {
|
||||
@@ -956,47 +819,6 @@ describe('appModeStore', () => {
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('does not enable Vue nodes after leaving select mode', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
mockSettings.set.mockClear()
|
||||
store.exitBuilder()
|
||||
await nextTick()
|
||||
|
||||
expect(mockSettings.set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('read only canvas sync', () => {
|
||||
it('keeps canvas read-only while in select mode', async () => {
|
||||
mockCanvas.state = reactive({ readOnly: false })
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
mockCanvas.state.readOnly = false
|
||||
await nextTick()
|
||||
|
||||
expect(mockCanvas.state.readOnly).toBe(true)
|
||||
})
|
||||
|
||||
it('stops enforcing read-only after leaving select mode', async () => {
|
||||
mockCanvas.state = reactive({ readOnly: false })
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
store.exitBuilder()
|
||||
await nextTick()
|
||||
mockCanvas.state.readOnly = false
|
||||
await nextTick()
|
||||
|
||||
expect(mockCanvas.state.readOnly).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy selectedInput tuple migration', () => {
|
||||
@@ -1085,121 +907,6 @@ describe('appModeStore', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('drops direct root-node widgets that cannot produce an entity id', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const sourceNodeId = 42
|
||||
const sourceWidgetName = 'text'
|
||||
const rootNode = fromAny<LGraphNode, unknown>({
|
||||
id: sourceNodeId,
|
||||
widgets: [{ name: sourceWidgetName }]
|
||||
})
|
||||
vi.mocked(app.rootGraph).id = rootGraphId
|
||||
vi.mocked(app.rootGraph).nodes = [rootNode]
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(
|
||||
(id: SerializedNodeId | null | undefined) =>
|
||||
id == sourceNodeId ? rootNode : null
|
||||
)
|
||||
|
||||
const result = store.pruneLinearData({
|
||||
inputs: [[sourceNodeId, sourceWidgetName, { height: 120 }]],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
expect(result.inputs).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('legacy selectedInput tuple'),
|
||||
expect.objectContaining({
|
||||
storedId: sourceNodeId,
|
||||
widgetName: sourceWidgetName
|
||||
})
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('drops promoted inputs whose source target no longer matches', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const subgraphInputName = 'Prompt'
|
||||
const sourceWidgetName = 'text'
|
||||
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: subgraphInputName, type: 'STRING' }]
|
||||
})
|
||||
const interior = new LGraphNodeClass('Interior')
|
||||
const interiorInput = interior.addInput(subgraphInputName, 'STRING')
|
||||
interior.addWidget('string', sourceWidgetName, '', () => undefined)
|
||||
interiorInput.widget = { name: sourceWidgetName }
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interior)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: 5 })
|
||||
const rootGraph = host.graph as LGraph
|
||||
rootGraph.add(host)
|
||||
host._internalConfigureAfterSlots()
|
||||
|
||||
vi.mocked(app.rootGraph).id = rootGraph.id
|
||||
vi.mocked(app.rootGraph).nodes = rootGraph.nodes
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
|
||||
rootGraph.getNodeById(id)
|
||||
)
|
||||
|
||||
const result = store.pruneLinearData({
|
||||
inputs: [[interior.id, 'other-widget', { height: 120 }]],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
expect(result.inputs).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('drops legacy inputs when multiple promoted inputs match', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const subgraphInputName = 'Prompt'
|
||||
const sourceWidgetName = 'text'
|
||||
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: subgraphInputName, type: 'STRING' }]
|
||||
})
|
||||
const interior = new LGraphNodeClass('Interior')
|
||||
const interiorInput = interior.addInput(subgraphInputName, 'STRING')
|
||||
interior.addWidget('string', sourceWidgetName, '', () => undefined)
|
||||
interiorInput.widget = { name: sourceWidgetName }
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interior)
|
||||
|
||||
const firstHost = createTestSubgraphNode(subgraph, { id: 5 })
|
||||
const rootGraph = firstHost.graph as LGraph
|
||||
const secondHost = createTestSubgraphNode(subgraph, {
|
||||
id: 6,
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
rootGraph.add(firstHost)
|
||||
rootGraph.add(secondHost)
|
||||
firstHost._internalConfigureAfterSlots()
|
||||
secondHost._internalConfigureAfterSlots()
|
||||
|
||||
vi.mocked(app.rootGraph).id = rootGraph.id
|
||||
vi.mocked(app.rootGraph).nodes = rootGraph.nodes
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn((id) =>
|
||||
rootGraph.getNodeById(id)
|
||||
)
|
||||
|
||||
const result = store.pruneLinearData({
|
||||
inputs: [[interior.id, sourceWidgetName, { height: 120 }]],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
expect(result.inputs).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ambiguous legacy selectedInput tuple'),
|
||||
expect.objectContaining({
|
||||
storedId: interior.id,
|
||||
widgetName: sourceWidgetName
|
||||
})
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('warns and drops a tuple whose target widget no longer resolves', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.mocked(app.rootGraph).id = rootGraphId
|
||||
|
||||
@@ -90,7 +90,6 @@ vi.mock('firebase/auth', async (importOriginal) => {
|
||||
onAuthStateChanged: vi.fn(),
|
||||
onIdTokenChanged: vi.fn(),
|
||||
signInWithPopup: vi.fn(),
|
||||
sendPasswordResetEmail: vi.fn(),
|
||||
GoogleAuthProvider: class {
|
||||
addScope = vi.fn()
|
||||
setCustomParameters = vi.fn()
|
||||
@@ -100,8 +99,7 @@ vi.mock('firebase/auth', async (importOriginal) => {
|
||||
setCustomParameters = vi.fn()
|
||||
},
|
||||
getAdditionalUserInfo: vi.fn(),
|
||||
setPersistence: vi.fn().mockResolvedValue(undefined),
|
||||
updatePassword: vi.fn()
|
||||
setPersistence: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -129,18 +127,6 @@ vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockWorkspaceAuthStore = vi.hoisted(() => ({
|
||||
unifiedToken: null as string | null,
|
||||
clearWorkspaceContext: vi.fn(),
|
||||
mintAtLogin: vi.fn(),
|
||||
getWorkspaceAuthHeader: vi.fn(),
|
||||
getWorkspaceToken: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
|
||||
}))
|
||||
|
||||
// Mock apiKeyAuthStore
|
||||
const mockApiKeyGetAuthHeader = vi.fn().mockReturnValue(null)
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
@@ -177,9 +163,6 @@ describe('useAuthStore', () => {
|
||||
|
||||
mockFeatureFlags.teamWorkspacesEnabled = false
|
||||
mockFeatureFlags.unifiedCloudAuthEnabled = false
|
||||
mockWorkspaceAuthStore.unifiedToken = null
|
||||
mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue(null)
|
||||
mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue(undefined)
|
||||
|
||||
// Setup dialog service mock
|
||||
vi.mocked(useDialogService, { partial: true }).mockReturnValue({
|
||||
@@ -292,11 +275,6 @@ describe('useAuthStore', () => {
|
||||
store.notifyTokenRefreshed()
|
||||
expect(store.tokenRefreshTrigger).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores null ID token events', () => {
|
||||
idTokenCallback?.(null)
|
||||
expect(store.tokenRefreshTrigger).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should initialize with the current user', () => {
|
||||
@@ -314,24 +292,6 @@ describe('useAuthStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('mints workspace auth on cloud login and clears it on logout state', () => {
|
||||
expect(mockWorkspaceAuthStore.mintAtLogin).toHaveBeenCalledOnce()
|
||||
|
||||
authStateCallback(null)
|
||||
|
||||
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not mint workspace auth outside cloud', () => {
|
||||
mockWorkspaceAuthStore.mintAtLogin.mockClear()
|
||||
mockDistributionTypes.isCloud = false
|
||||
|
||||
authStateCallback(mockUser)
|
||||
|
||||
expect(mockWorkspaceAuthStore.mintAtLogin).not.toHaveBeenCalled()
|
||||
mockDistributionTypes.isCloud = true
|
||||
})
|
||||
|
||||
it('should properly clean up error state between operations', async () => {
|
||||
// First, cause an error
|
||||
const mockError = new Error('Invalid password')
|
||||
@@ -389,30 +349,6 @@ describe('useAuthStore', () => {
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks login when Firebase returns no email', async () => {
|
||||
const userWithoutEmail = { ...mockUser, email: null }
|
||||
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue({
|
||||
user: userWithoutEmail
|
||||
} as Partial<UserCredential> as UserCredential)
|
||||
|
||||
await store.login('test@example.com', 'password')
|
||||
|
||||
expect(mockTrackAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: undefined })
|
||||
)
|
||||
})
|
||||
|
||||
it('fails customer creation when the signed-in user has no token yet', async () => {
|
||||
authStateCallback(null)
|
||||
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue({
|
||||
user: mockUser
|
||||
} as Partial<UserCredential> as UserCredential)
|
||||
|
||||
await expect(store.login('test@example.com', 'password')).rejects.toThrow(
|
||||
'Cannot create customer: User not authenticated'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle concurrent login attempts correctly', async () => {
|
||||
// Set up multiple login promises
|
||||
const mockUserCredential = { user: mockUser }
|
||||
@@ -550,19 +486,6 @@ describe('useAuthStore', () => {
|
||||
).rejects.toThrow()
|
||||
expect(mockUser.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks registration when Firebase returns no email', async () => {
|
||||
const userWithoutEmail = { ...mockUser, email: null }
|
||||
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue({
|
||||
user: userWithoutEmail
|
||||
} as Partial<UserCredential> as UserCredential)
|
||||
|
||||
await store.register('new@example.com', 'password')
|
||||
|
||||
expect(mockTrackAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: undefined })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
@@ -696,54 +619,6 @@ describe('useAuthStore', () => {
|
||||
const authHeader = await store.getAuthHeader()
|
||||
expect(authHeader).toBeNull() // Should fallback gracefully
|
||||
})
|
||||
|
||||
it('uses the unified cloud token when enabled', async () => {
|
||||
mockFeatureFlags.unifiedCloudAuthEnabled = true
|
||||
mockWorkspaceAuthStore.unifiedToken = 'unified-token'
|
||||
|
||||
await expect(store.getAuthHeader()).resolves.toEqual({
|
||||
Authorization: 'Bearer unified-token'
|
||||
})
|
||||
await expect(store.getAuthToken()).resolves.toBe('unified-token')
|
||||
})
|
||||
|
||||
it('returns no unified auth when the unified token is missing', async () => {
|
||||
mockFeatureFlags.unifiedCloudAuthEnabled = true
|
||||
mockWorkspaceAuthStore.unifiedToken = null
|
||||
|
||||
await expect(store.getAuthHeader()).resolves.toBeNull()
|
||||
await expect(store.getAuthToken()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers workspace auth when team workspaces are enabled', async () => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue({
|
||||
Authorization: 'Bearer workspace-header'
|
||||
})
|
||||
mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue(
|
||||
'workspace-token'
|
||||
)
|
||||
|
||||
await expect(store.getAuthHeader()).resolves.toEqual({
|
||||
Authorization: 'Bearer workspace-header'
|
||||
})
|
||||
await expect(store.getAuthToken()).resolves.toBe('workspace-token')
|
||||
})
|
||||
|
||||
it('falls back to Firebase when workspace auth is unavailable', async () => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockWorkspaceAuthStore.getWorkspaceAuthHeader.mockReturnValue(null)
|
||||
mockWorkspaceAuthStore.getWorkspaceToken.mockReturnValue(undefined)
|
||||
|
||||
await expect(store.getAuthHeader()).resolves.toEqual({
|
||||
Authorization: 'Bearer mock-id-token'
|
||||
})
|
||||
await expect(store.getAuthToken()).resolves.toBe('mock-id-token')
|
||||
})
|
||||
|
||||
it('returns the Firebase token by default', async () => {
|
||||
await expect(store.getAuthToken()).resolves.toBe('mock-id-token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('social authentication', () => {
|
||||
@@ -929,22 +804,6 @@ describe('useAuthStore', () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.for(['loginWithGoogle', 'loginWithGithub'] as const)(
|
||||
'%s should track undefined email when Firebase returns no email',
|
||||
async (method) => {
|
||||
const userWithoutEmail = { ...mockUser, email: null }
|
||||
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue({
|
||||
user: userWithoutEmail
|
||||
} as Partial<UserCredential> as UserCredential)
|
||||
|
||||
await store[method]()
|
||||
|
||||
expect(mockTrackAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: undefined })
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1116,61 +975,6 @@ describe('useAuthStore', () => {
|
||||
|
||||
await expect(store.accessBillingPortal()).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('throws when no auth method is available', async () => {
|
||||
authStateCallback(null)
|
||||
mockApiKeyGetAuthHeader.mockReturnValue(null)
|
||||
|
||||
await expect(store.accessBillingPortal()).rejects.toMatchObject({
|
||||
name: 'AuthStoreError',
|
||||
message: 'toastMessages.userNotAuthenticated'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchBalance', () => {
|
||||
it('stores the balance and update time when fetching succeeds', async () => {
|
||||
await expect(store.fetchBalance()).resolves.toEqual({ balance: 0 })
|
||||
|
||||
expect(store.balance).toEqual({ balance: 0 })
|
||||
expect(store.lastBalanceUpdateTime).toBeInstanceOf(Date)
|
||||
expect(store.isFetchingBalance).toBe(false)
|
||||
})
|
||||
|
||||
it('throws when no auth method is available', async () => {
|
||||
authStateCallback(null)
|
||||
mockApiKeyGetAuthHeader.mockReturnValue(null)
|
||||
|
||||
await expect(store.fetchBalance()).rejects.toMatchObject({
|
||||
name: 'AuthStoreError',
|
||||
message: 'toastMessages.userNotAuthenticated'
|
||||
})
|
||||
expect(store.isFetchingBalance).toBe(false)
|
||||
})
|
||||
|
||||
it('returns null when the customer balance is missing', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404
|
||||
})
|
||||
|
||||
await expect(store.fetchBalance()).resolves.toBeNull()
|
||||
expect(store.balance).toBeNull()
|
||||
expect(store.isFetchingBalance).toBe(false)
|
||||
})
|
||||
|
||||
it('throws API errors when fetching balance fails', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ message: 'Balance unavailable' })
|
||||
})
|
||||
|
||||
await expect(store.fetchBalance()).rejects.toThrow(
|
||||
'toastMessages.failedToFetchBalance'
|
||||
)
|
||||
expect(store.isFetchingBalance).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAuthHeaderOrThrow', () => {
|
||||
@@ -1258,117 +1062,5 @@ describe('useAuthStore', () => {
|
||||
expect(error).toBeInstanceOf(AuthStoreError)
|
||||
expect((error as AuthStoreError).status).toBe(422)
|
||||
})
|
||||
|
||||
it('throws when the response has no customer id', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({})
|
||||
})
|
||||
|
||||
await expect(store.createCustomer()).rejects.toThrow(
|
||||
'toastMessages.failedToCreateCustomer'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password actions', () => {
|
||||
it('sends password reset emails', async () => {
|
||||
vi.mocked(firebaseAuth.sendPasswordResetEmail).mockResolvedValue()
|
||||
|
||||
await store.sendPasswordReset('test@example.com')
|
||||
|
||||
expect(firebaseAuth.sendPasswordResetEmail).toHaveBeenCalledWith(
|
||||
mockAuth,
|
||||
'test@example.com'
|
||||
)
|
||||
})
|
||||
|
||||
it('updates the current user password', async () => {
|
||||
vi.mocked(firebaseAuth.updatePassword).mockResolvedValue()
|
||||
|
||||
await store.updatePassword('new-password')
|
||||
|
||||
expect(firebaseAuth.updatePassword).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
'new-password'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when updating password without a user', async () => {
|
||||
authStateCallback(null)
|
||||
|
||||
await expect(store.updatePassword('new-password')).rejects.toMatchObject({
|
||||
name: 'AuthStoreError',
|
||||
message: 'toastMessages.userNotAuthenticated'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('initiateCreditPurchase', () => {
|
||||
it('creates the customer once before adding credits', async () => {
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url.endsWith('/customers')) {
|
||||
return Promise.resolve(mockCreateCustomerResponse)
|
||||
}
|
||||
if (url.endsWith('/customers/credit')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ redirect_url: 'https://stripe.test' })
|
||||
})
|
||||
}
|
||||
return Promise.reject(new Error('Unexpected API call'))
|
||||
})
|
||||
|
||||
await store.initiateCreditPurchase({
|
||||
amount_micros: 10_000_000,
|
||||
currency: 'usd'
|
||||
})
|
||||
await store.initiateCreditPurchase({
|
||||
amount_micros: 10_000_000,
|
||||
currency: 'usd'
|
||||
})
|
||||
|
||||
const customerCalls = mockFetch.mock.calls.filter(([url]) =>
|
||||
String(url).endsWith('/customers')
|
||||
)
|
||||
expect(customerCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('throws when credit purchase fails', async () => {
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url.endsWith('/customers')) {
|
||||
return Promise.resolve(mockCreateCustomerResponse)
|
||||
}
|
||||
if (url.endsWith('/customers/credit')) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ message: 'Checkout unavailable' })
|
||||
})
|
||||
}
|
||||
return Promise.reject(new Error('Unexpected API call'))
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.initiateCreditPurchase({
|
||||
amount_micros: 10_000_000,
|
||||
currency: 'usd'
|
||||
})
|
||||
).rejects.toThrow('toastMessages.failedToInitiateCreditPurchase')
|
||||
})
|
||||
|
||||
it('throws when no auth method is available', async () => {
|
||||
authStateCallback(null)
|
||||
mockApiKeyGetAuthHeader.mockReturnValue(null)
|
||||
|
||||
await expect(
|
||||
store.initiateCreditPurchase({
|
||||
amount_micros: 10_000_000,
|
||||
currency: 'usd'
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: 'AuthStoreError',
|
||||
message: 'toastMessages.userNotAuthenticated'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,17 +93,6 @@ describe('bootstrapStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not reload authenticated stores after bootstrap already ran', async () => {
|
||||
const store = useBootstrapStore()
|
||||
|
||||
await store.startStoreBootstrap()
|
||||
await store.startStoreBootstrap()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.isI18nReady).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloud mode', () => {
|
||||
beforeEach(() => {
|
||||
mockDistributionTypes.isCloud = true
|
||||
|
||||
@@ -4,10 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const keybindingMock = vi.hoisted(() => ({
|
||||
value: null as null | { combo: { getKeySequences: () => string[] } }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandlingAsync:
|
||||
@@ -25,13 +21,12 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({
|
||||
getKeybindingByCommandId: () => keybindingMock.value
|
||||
getKeybindingByCommandId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
describe('commandStore', () => {
|
||||
beforeEach(() => {
|
||||
keybindingMock.value = null
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
@@ -169,16 +164,6 @@ describe('commandStore', () => {
|
||||
expect(store.getCommand('tip.fn')?.tooltip).toBe('Dynamic tip')
|
||||
})
|
||||
|
||||
it('resolves icon as function', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'icon.fn',
|
||||
function: vi.fn(),
|
||||
icon: () => 'pi pi-bolt'
|
||||
})
|
||||
expect(store.getCommand('icon.fn')?.icon).toBe('pi pi-bolt')
|
||||
})
|
||||
|
||||
it('uses explicit menubarLabel over label', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
@@ -199,16 +184,6 @@ describe('commandStore', () => {
|
||||
})
|
||||
expect(store.getCommand('mbl.default')?.menubarLabel).toBe('My Label')
|
||||
})
|
||||
|
||||
it('resolves menubarLabel as function', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'mbl.fn',
|
||||
function: vi.fn(),
|
||||
menubarLabel: () => 'Dynamic menu'
|
||||
})
|
||||
expect(store.getCommand('mbl.fn')?.menubarLabel).toBe('Dynamic menu')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatKeySequence', () => {
|
||||
@@ -218,17 +193,5 @@ describe('commandStore', () => {
|
||||
const cmd = store.getCommand('no.kb')!
|
||||
expect(store.formatKeySequence(cmd)).toBe('')
|
||||
})
|
||||
|
||||
it('formats keybinding sequences', () => {
|
||||
const store = useCommandStore()
|
||||
keybindingMock.value = {
|
||||
combo: { getKeySequences: () => ['Control+A', 'Shift+B'] }
|
||||
}
|
||||
store.registerCommand({ id: 'with.kb', function: vi.fn() })
|
||||
|
||||
const cmd = store.getCommand('with.kb')!
|
||||
|
||||
expect(store.formatKeySequence(cmd)).toBe('Ctrl+A + Shift+B')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -141,110 +141,6 @@ describe('dialogStore', () => {
|
||||
})
|
||||
|
||||
describe('basic dialog operations', () => {
|
||||
it('generates a key when none is provided', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
const dialog = store.showDialog({ component: MockComponent })
|
||||
|
||||
expect(dialog.key).toMatch(/^dialog-/)
|
||||
expect(store.isDialogOpen(dialog.key)).toBe(true)
|
||||
})
|
||||
|
||||
it('evicts the first stack entry when the stack is full', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
store.showDialog({
|
||||
key: `dialog-${i}`,
|
||||
component: MockComponent,
|
||||
priority: i
|
||||
})
|
||||
}
|
||||
|
||||
expect(store.dialogStack).toHaveLength(10)
|
||||
expect(store.isDialogOpen('dialog-9')).toBe(false)
|
||||
})
|
||||
|
||||
it('stores optional header and footer components and props', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
const dialog = store.showDialog({
|
||||
key: 'with-slots',
|
||||
component: MockComponent,
|
||||
headerComponent: MockComponent,
|
||||
footerComponent: MockComponent,
|
||||
headerProps: { title: 'Header' },
|
||||
footerProps: { action: 'Save' }
|
||||
})
|
||||
|
||||
expect(dialog.headerComponent).toBeDefined()
|
||||
expect(dialog.footerComponent).toBeDefined()
|
||||
expect(dialog.headerProps).toEqual({ title: 'Header' })
|
||||
expect(dialog.footerProps).toEqual({ action: 'Save' })
|
||||
})
|
||||
|
||||
it('runs dialog lifecycle handlers', () => {
|
||||
const store = useDialogStore()
|
||||
const onClose = vi.fn()
|
||||
const dialog = store.showDialog({
|
||||
key: 'lifecycle',
|
||||
component: MockComponent,
|
||||
dialogComponentProps: { onClose }
|
||||
})
|
||||
const props =
|
||||
dialog.dialogComponentProps as typeof dialog.dialogComponentProps & {
|
||||
onAfterHide: () => void
|
||||
onMaximize: () => void
|
||||
onUnmaximize: () => void
|
||||
pt: { root: { onMousedown: () => void } }
|
||||
}
|
||||
|
||||
props.onMaximize()
|
||||
expect(dialog.dialogComponentProps.maximized).toBe(true)
|
||||
|
||||
props.onUnmaximize()
|
||||
expect(dialog.dialogComponentProps.maximized).toBe(false)
|
||||
|
||||
props.pt.root.onMousedown()
|
||||
expect(store.activeKey).toBe('lifecycle')
|
||||
|
||||
props.onAfterHide()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(store.isDialogOpen('lifecycle')).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing when rising or closing a missing dialog', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
store.riseDialog({ key: 'missing' })
|
||||
store.closeDialog({ key: 'missing' })
|
||||
|
||||
expect(store.dialogStack).toEqual([])
|
||||
expect(store.activeKey).toBeNull()
|
||||
})
|
||||
|
||||
it('closes the active dialog when no key is provided', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({ key: 'active', component: MockComponent })
|
||||
store.closeDialog()
|
||||
|
||||
expect(store.isDialogOpen('active')).toBe(false)
|
||||
expect(store.activeKey).toBeNull()
|
||||
})
|
||||
|
||||
it('disables escape closing for a non-closable active dialog', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
const dialog = store.showDialog({
|
||||
key: 'locked',
|
||||
component: MockComponent,
|
||||
dialogComponentProps: { closable: false }
|
||||
})
|
||||
|
||||
expect(dialog.dialogComponentProps.closeOnEscape).toBe(false)
|
||||
})
|
||||
|
||||
it('should show and close dialogs', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
@@ -312,86 +208,6 @@ describe('dialogStore', () => {
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('updates only content props when dialog component props are omitted', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'content-only',
|
||||
component: MockContentPropsComponent,
|
||||
props: { openingAction: null }
|
||||
})
|
||||
|
||||
expect(
|
||||
store.updateDialog({
|
||||
key: 'content-only',
|
||||
contentProps: { openingAction: 'open' }
|
||||
})
|
||||
).toBe(true)
|
||||
expect(store.dialogStack[0].contentProps.openingAction).toBe('open')
|
||||
})
|
||||
|
||||
it('updates only dialog component props when content props are omitted', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'dialog-props-only',
|
||||
component: MockContentPropsComponent,
|
||||
dialogComponentProps: { dismissableMask: true }
|
||||
})
|
||||
|
||||
expect(
|
||||
store.updateDialog({
|
||||
key: 'dialog-props-only',
|
||||
dialogComponentProps: { dismissableMask: false }
|
||||
})
|
||||
).toBe(true)
|
||||
expect(store.dialogStack[0].dialogComponentProps.dismissableMask).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when updating a missing dialog', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
expect(
|
||||
store.updateDialog({
|
||||
key: 'missing',
|
||||
contentProps: { openingAction: 'open' }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('creates and reuses extension dialogs with extension-prefixed keys', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
const first = store.showExtensionDialog({
|
||||
key: 'external',
|
||||
component: MockComponent
|
||||
})
|
||||
const second = store.showExtensionDialog({
|
||||
key: 'extension-external',
|
||||
component: MockComponent
|
||||
})
|
||||
|
||||
expect(first?.key).toBe('extension-external')
|
||||
expect(second?.key).toBe(first?.key)
|
||||
expect(store.dialogStack).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects extension dialogs without keys', () => {
|
||||
const store = useDialogStore()
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const dialog = store.showExtensionDialog({
|
||||
key: '',
|
||||
component: MockComponent
|
||||
})
|
||||
|
||||
expect(dialog).toBeUndefined()
|
||||
expect(error).toHaveBeenCalledWith('Extension dialog key is required')
|
||||
error.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ESC key behavior with multiple dialogs', () => {
|
||||
|
||||
@@ -112,36 +112,6 @@ describe('domWidgetStore', () => {
|
||||
store.activateWidget('non-existent')
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should ignore deactivating non-existent widgets', () => {
|
||||
store.deactivateWidget('non-existent')
|
||||
|
||||
expect(store.widgetStates.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should replace registered widgets', () => {
|
||||
const widget = createMockDOMWidget('widget-1')
|
||||
const replacement = {
|
||||
...createMockDOMWidget('widget-1'),
|
||||
value: 'replacement'
|
||||
}
|
||||
store.registerWidget(widget)
|
||||
store.deactivateWidget('widget-1')
|
||||
|
||||
store.setWidget(replacement)
|
||||
|
||||
const state = store.widgetStates.get('widget-1')
|
||||
expect(state?.widget.value).toBe('replacement')
|
||||
expect(state?.active).toBe(true)
|
||||
})
|
||||
|
||||
it('should ignore missing widgets when replacing', () => {
|
||||
const widget = createMockDOMWidget('widget-1')
|
||||
|
||||
store.setWidget(widget)
|
||||
|
||||
expect(store.widgetStates.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed states', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
import { releaseSharedObjectUrl } from '@/utils/objectUrlUtil'
|
||||
@@ -71,14 +71,6 @@ describe('jobPreviewStore', () => {
|
||||
expect(store.previewsByPromptId).toEqual({ p2: 'blob:b' })
|
||||
})
|
||||
|
||||
it('ignores clearPreview without a prompt id', () => {
|
||||
const store = useJobPreviewStore()
|
||||
|
||||
store.clearPreview(undefined)
|
||||
|
||||
expect(store.nodePreviewsByPromptId).toEqual({})
|
||||
})
|
||||
|
||||
it('clears all previews', () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
@@ -99,24 +91,6 @@ describe('jobPreviewStore', () => {
|
||||
expect(releaseSharedObjectUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores missing prompt ids', () => {
|
||||
const store = useJobPreviewStore()
|
||||
|
||||
store.setPreviewUrl(undefined, 'blob:a', 'node-1')
|
||||
|
||||
expect(store.nodePreviewsByPromptId).toEqual({})
|
||||
})
|
||||
|
||||
it('releases the old url when replacing a preview', () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
|
||||
store.setPreviewUrl('p1', 'blob:b', 'node-1')
|
||||
|
||||
expect(releaseSharedObjectUrl).toHaveBeenCalledWith('blob:a')
|
||||
expect(store.nodePreviewsByPromptId['p1']?.url).toBe('blob:b')
|
||||
})
|
||||
|
||||
it('ignores setPreviewUrl when previews are disabled', () => {
|
||||
previewMethodRef.value = 'none'
|
||||
const store = useJobPreviewStore()
|
||||
@@ -125,15 +99,4 @@ describe('jobPreviewStore', () => {
|
||||
|
||||
expect(store.nodePreviewsByPromptId).toEqual({})
|
||||
})
|
||||
|
||||
it('clears previews when previews are disabled after storage', async () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
|
||||
previewMethodRef.value = 'none'
|
||||
await nextTick()
|
||||
|
||||
expect(store.nodePreviewsByPromptId).toEqual({})
|
||||
expect(releaseSharedObjectUrl).toHaveBeenCalledWith('blob:a')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
|
||||
const canvasStoreMock = vi.hoisted(() => ({ linearMode: false }))
|
||||
|
||||
vi.mock('@/constants/coreMenuCommands', () => ({
|
||||
CORE_MENU_COMMANDS: [[['Core'], ['core.command']]]
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandlingAsync:
|
||||
(fn: () => Promise<void>, errorHandler?: (e: unknown) => void) =>
|
||||
async () => {
|
||||
try {
|
||||
await fn()
|
||||
} catch (e) {
|
||||
if (errorHandler) errorHandler(e)
|
||||
else throw e
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({
|
||||
getKeybindingByCommandId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => canvasStoreMock
|
||||
}))
|
||||
|
||||
describe('menuItemStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
canvasStoreMock.linearMode = false
|
||||
})
|
||||
|
||||
it('records that linear mode has been seen', () => {
|
||||
canvasStoreMock.linearMode = true
|
||||
|
||||
const store = useMenuItemStore()
|
||||
|
||||
expect(store.hasSeenLinear).toBe(true)
|
||||
})
|
||||
|
||||
it('creates nested groups, separators, and active-state metadata', () => {
|
||||
const store = useMenuItemStore()
|
||||
const activeItem: MenuItem = {
|
||||
label: 'Active',
|
||||
comfyCommand: { id: 'active', function: vi.fn(), active: () => true }
|
||||
}
|
||||
const plainItem: MenuItem = { label: 'Plain' }
|
||||
|
||||
store.registerMenuGroup(['File', 'Export'], [activeItem])
|
||||
store.registerMenuGroup(['File', 'Export'], [plainItem])
|
||||
|
||||
const file = store.menuItems[0]
|
||||
const exportGroup = file.items?.[0]
|
||||
|
||||
expect(file.label).toBe('File')
|
||||
expect(exportGroup?.items).toEqual([
|
||||
activeItem,
|
||||
{ separator: true },
|
||||
plainItem
|
||||
])
|
||||
expect(store.menuItemHasActiveStateChildren['File.Export']).toBe(true)
|
||||
})
|
||||
|
||||
it('repairs existing group items before appending children', () => {
|
||||
const store = useMenuItemStore()
|
||||
store.menuItems.push({ label: 'Tools' })
|
||||
|
||||
store.registerMenuGroup(['Tools'], [{ label: 'Child' }])
|
||||
|
||||
expect(store.menuItems[0].items).toEqual([{ label: 'Child' }])
|
||||
})
|
||||
|
||||
it('maps command ids to executable menu items', async () => {
|
||||
const commandStore = useCommandStore()
|
||||
const fn = vi.fn()
|
||||
commandStore.registerCommand({
|
||||
id: 'test.command',
|
||||
function: fn,
|
||||
icon: 'icon-[lucide--test]',
|
||||
label: 'Label',
|
||||
menubarLabel: 'Menu Label',
|
||||
tooltip: 'Tip'
|
||||
})
|
||||
|
||||
const store = useMenuItemStore()
|
||||
const item = store.commandIdToMenuItem('test.command', ['Tools'])
|
||||
await item.command?.({ originalEvent: new Event('click'), item })
|
||||
|
||||
expect(fn).toHaveBeenCalled()
|
||||
expect(item).toMatchObject({
|
||||
label: 'Menu Label',
|
||||
icon: 'icon-[lucide--test]',
|
||||
tooltip: 'Tip',
|
||||
parentPath: 'Tools'
|
||||
})
|
||||
})
|
||||
|
||||
it('loads extension menu commands only for commands owned by the extension', () => {
|
||||
const commandStore = useCommandStore()
|
||||
commandStore.registerCommand({
|
||||
id: 'owned',
|
||||
function: vi.fn(),
|
||||
menubarLabel: 'Owned'
|
||||
})
|
||||
|
||||
const store = useMenuItemStore()
|
||||
store.loadExtensionMenuCommands({
|
||||
name: 'extension',
|
||||
commands: [{ id: 'owned', function: vi.fn() }],
|
||||
menuCommands: [{ path: ['Tools'], commands: ['owned', 'external'] }]
|
||||
})
|
||||
store.loadExtensionMenuCommands({ name: 'plain' })
|
||||
store.loadExtensionMenuCommands({
|
||||
name: 'empty',
|
||||
menuCommands: [{ path: ['Tools'], commands: ['missing'] }]
|
||||
})
|
||||
|
||||
expect(store.menuItems[0].items?.map((item) => item.label)).toEqual([
|
||||
'Owned'
|
||||
])
|
||||
})
|
||||
|
||||
it('registers core menu commands', () => {
|
||||
const commandStore = useCommandStore()
|
||||
commandStore.registerCommand({
|
||||
id: 'core.command',
|
||||
function: vi.fn(),
|
||||
menubarLabel: 'Core Command'
|
||||
})
|
||||
|
||||
const store = useMenuItemStore()
|
||||
store.registerCoreMenuCommands()
|
||||
|
||||
expect(store.menuItems[0].items?.[0].label).toBe('Core Command')
|
||||
})
|
||||
})
|
||||
@@ -95,22 +95,6 @@ describe(usePreviewExposureStore, () => {
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
|
||||
})
|
||||
|
||||
it('clears only the requested host when other hosts remain', () => {
|
||||
store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
store.addExposure(rootGraphA, hostB, {
|
||||
sourceNodeId: '43',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
|
||||
store.setExposures(rootGraphA, hostA, [])
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
|
||||
expect(store.getExposures(rootGraphA, hostB)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeExposure', () => {
|
||||
@@ -138,12 +122,6 @@ describe(usePreviewExposureStore, () => {
|
||||
store.removeExposure(rootGraphA, hostA, 'does-not-exist')
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual(before)
|
||||
})
|
||||
|
||||
it('is a no-op for an unknown host', () => {
|
||||
store.removeExposure(rootGraphA, 'missing-host', 'preview')
|
||||
|
||||
expect(store.getExposures(rootGraphA, 'missing-host')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getExposuresAsPromotionShape', () => {
|
||||
|
||||
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