mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-05 03:59:09 +00:00
Compare commits
4 Commits
proxy-widg
...
pysssss/ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89e634d431 | ||
|
|
49e052ef97 | ||
|
|
83eed12c0e | ||
|
|
e2d96e05f9 |
224
src/components/topbar/WorkflowTab.test.ts
Normal file
224
src/components/topbar/WorkflowTab.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { WorkflowStatus } from '@/stores/executionStore'
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
currentUser: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false
|
||||
})
|
||||
}))
|
||||
|
||||
const mockExecutionStore = reactive({
|
||||
workflowStatus: new Map<string, WorkflowStatus>(),
|
||||
clearWorkflowStatus: vi.fn()
|
||||
})
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => mockExecutionStore
|
||||
}))
|
||||
|
||||
vi.mock('reka-ui', () => {
|
||||
const passthrough = {
|
||||
render() {
|
||||
return (
|
||||
this as unknown as { $slots: { default?: () => unknown } }
|
||||
).$slots.default?.()
|
||||
}
|
||||
}
|
||||
return {
|
||||
ContextMenuRoot: passthrough,
|
||||
ContextMenuTrigger: passthrough,
|
||||
ContextMenuContent: passthrough,
|
||||
ContextMenuItem: passthrough,
|
||||
ContextMenuPortal: passthrough,
|
||||
ContextMenuSeparator: passthrough,
|
||||
Primitive: passthrough
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/usePragmaticDragAndDrop', () => ({
|
||||
usePragmaticDraggable: vi.fn(),
|
||||
usePragmaticDroppable: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowActionsMenu', () => ({
|
||||
useWorkflowActionsMenu: () => ({
|
||||
menuItems: { value: [] }
|
||||
})
|
||||
}))
|
||||
|
||||
const mockCloseWorkflow = vi.fn().mockResolvedValue(true)
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
closeWorkflow: mockCloseWorkflow
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
|
||||
useWorkflowThumbnail: () => ({
|
||||
getThumbnail: vi.fn(() => null)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./WorkflowTabPopover.vue', () => ({
|
||||
default: { render: () => null }
|
||||
}))
|
||||
|
||||
import WorkflowTab from './WorkflowTab.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
type WorkflowTabProps = ComponentProps<typeof WorkflowTab>
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { close: 'Close' } } }
|
||||
})
|
||||
|
||||
function makeWorkflowOption(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
value: 'test-key',
|
||||
workflow: {
|
||||
key: 'test-key',
|
||||
path: '/workflows/test.json',
|
||||
filename: 'test.json',
|
||||
isPersisted: true,
|
||||
isModified: false,
|
||||
initialMode: 'default',
|
||||
activeMode: 'default',
|
||||
changeTracker: null,
|
||||
...overrides
|
||||
}
|
||||
} as unknown as WorkflowTabProps['workflowOption']
|
||||
}
|
||||
|
||||
function mountTab({
|
||||
workflowOverrides = {},
|
||||
activeWorkflowKey = 'other-key'
|
||||
} = {}) {
|
||||
return mount(WorkflowTab, {
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
workspace: { shiftDown: false },
|
||||
workflow: {
|
||||
activeWorkflow: { key: activeWorkflowKey }
|
||||
},
|
||||
setting: {}
|
||||
}
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
stubs: {
|
||||
WorkflowActionsList: true,
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
workflowOption: makeWorkflowOption(workflowOverrides),
|
||||
isFirst: false,
|
||||
isLast: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function findIndicator(wrapper: ReturnType<typeof mountTab>) {
|
||||
return wrapper.find('[data-testid="job-state-indicator"]')
|
||||
}
|
||||
|
||||
describe('WorkflowTab - job state indicator', () => {
|
||||
beforeEach(() => {
|
||||
mockExecutionStore.workflowStatus = new Map()
|
||||
mockExecutionStore.clearWorkflowStatus.mockClear()
|
||||
})
|
||||
|
||||
it.for(['running', 'completed', 'failed'] as const)(
|
||||
'shows %s indicator from store',
|
||||
(status) => {
|
||||
mockExecutionStore.workflowStatus = new Map([
|
||||
['/workflows/test.json', status]
|
||||
])
|
||||
|
||||
const wrapper = mountTab()
|
||||
const indicator = findIndicator(wrapper)
|
||||
expect(indicator.exists()).toBe(true)
|
||||
expect(indicator.attributes('data-state')).toBe(status)
|
||||
}
|
||||
)
|
||||
|
||||
it('shows unsaved dot when no job state and workflow is unsaved', () => {
|
||||
const wrapper = mountTab({
|
||||
workflowOverrides: { isPersisted: false }
|
||||
})
|
||||
expect(findIndicator(wrapper).exists()).toBe(false)
|
||||
const dot = wrapper.find('[data-testid="unsaved-indicator"]')
|
||||
expect(dot.exists()).toBe(true)
|
||||
expect(dot.text()).toBe('•')
|
||||
})
|
||||
|
||||
it('does not show job indicator on active tab', () => {
|
||||
mockExecutionStore.workflowStatus = new Map([
|
||||
['/workflows/test.json', 'completed']
|
||||
])
|
||||
|
||||
const wrapper = mountTab({ activeWorkflowKey: 'test-key' })
|
||||
expect(findIndicator(wrapper).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('job state replaces unsaved dot', () => {
|
||||
mockExecutionStore.workflowStatus = new Map([
|
||||
['/workflows/test.json', 'running']
|
||||
])
|
||||
|
||||
const wrapper = mountTab({
|
||||
workflowOverrides: { isPersisted: false }
|
||||
})
|
||||
const indicator = findIndicator(wrapper)
|
||||
expect(indicator.exists()).toBe(true)
|
||||
expect(indicator.attributes('data-state')).toBe('running')
|
||||
})
|
||||
|
||||
it('clears workflow status when tab becomes active', async () => {
|
||||
mockExecutionStore.workflowStatus = new Map([
|
||||
['/workflows/test.json', 'completed']
|
||||
])
|
||||
|
||||
const wrapper = mountTab()
|
||||
expect(findIndicator(wrapper).exists()).toBe(true)
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
workflowStore.activeWorkflow = {
|
||||
key: 'test-key'
|
||||
} satisfies Partial<LoadedComfyWorkflow> as LoadedComfyWorkflow
|
||||
await nextTick()
|
||||
|
||||
expect(mockExecutionStore.clearWorkflowStatus).toHaveBeenCalledWith(
|
||||
'/workflows/test.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears workflow status when close succeeds', async () => {
|
||||
mockCloseWorkflow.mockResolvedValue(true)
|
||||
|
||||
const wrapper = mountTab()
|
||||
await wrapper.find('button[aria-label="Close"]').trigger('click')
|
||||
|
||||
expect(mockCloseWorkflow).toHaveBeenCalled()
|
||||
expect(mockExecutionStore.clearWorkflowStatus).toHaveBeenCalledWith(
|
||||
'/workflows/test.json'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -21,8 +21,26 @@
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<i
|
||||
v-if="jobState"
|
||||
data-testid="job-state-indicator"
|
||||
:data-state="jobState"
|
||||
:aria-label="jobStateLabel"
|
||||
:class="
|
||||
cn(
|
||||
'absolute top-1/2 left-1/2 z-10 size-4 -translate-1/2 group-hover:hidden',
|
||||
jobState === 'running' &&
|
||||
'icon-[lucide--loader-circle] animate-spin text-muted-foreground',
|
||||
jobState === 'completed' &&
|
||||
'icon-[lucide--circle-check] text-success-background',
|
||||
jobState === 'failed' &&
|
||||
'icon-[lucide--octagon-alert] text-destructive-background'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-if="shouldShowStatusIndicator"
|
||||
v-else-if="shouldShowStatusIndicator"
|
||||
data-testid="unsaved-indicator"
|
||||
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
|
||||
>•</span
|
||||
>
|
||||
@@ -68,7 +86,7 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
@@ -84,8 +102,10 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||
|
||||
@@ -112,6 +132,7 @@ const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const workflowTabRef = ref<HTMLElement | null>(null)
|
||||
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
|
||||
const workflowThumbnail = useWorkflowThumbnail()
|
||||
@@ -159,6 +180,27 @@ const isActiveTab = computed(() => {
|
||||
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
|
||||
})
|
||||
|
||||
const jobState = computed(() => {
|
||||
const path = props.workflowOption.workflow.path
|
||||
if (!path || isActiveTab.value) return null
|
||||
return executionStore.workflowStatus.get(path) ?? null
|
||||
})
|
||||
|
||||
const jobStateLabelMap: Record<string, string> = {
|
||||
running: 'g.running',
|
||||
completed: 'g.completed',
|
||||
failed: 'g.failed'
|
||||
}
|
||||
|
||||
const jobStateLabel = computed(() =>
|
||||
jobState.value ? t(jobStateLabelMap[jobState.value]) : undefined
|
||||
)
|
||||
|
||||
watch(isActiveTab, (isActive) => {
|
||||
const path = props.workflowOption.workflow.path
|
||||
if (isActive && path) executionStore.clearWorkflowStatus(path)
|
||||
})
|
||||
|
||||
const thumbnailUrl = computed(() => {
|
||||
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
|
||||
})
|
||||
@@ -191,6 +233,8 @@ const closeWorkflows = async (options: WorkflowOption[]) => {
|
||||
// User clicked cancel
|
||||
break
|
||||
}
|
||||
const path = opt.workflow.path
|
||||
if (path) executionStore.clearWorkflowStatus(path)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -430,6 +430,114 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - workflowStatus', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
function fireExecutionStart(jobId: string) {
|
||||
const handler = apiEventHandlers.get('execution_start')
|
||||
if (!handler) throw new Error('execution_start handler not bound')
|
||||
handler(
|
||||
new CustomEvent('execution_start', { detail: { prompt_id: jobId } })
|
||||
)
|
||||
}
|
||||
|
||||
function fireExecutionSuccess(jobId: string) {
|
||||
const handler = apiEventHandlers.get('execution_success')
|
||||
if (!handler) throw new Error('execution_success handler not bound')
|
||||
handler(
|
||||
new CustomEvent('execution_success', { detail: { prompt_id: jobId } })
|
||||
)
|
||||
}
|
||||
|
||||
function fireExecutionError(jobId: string) {
|
||||
const handler = apiEventHandlers.get('execution_error')
|
||||
if (!handler) throw new Error('execution_error handler not bound')
|
||||
handler(
|
||||
new CustomEvent('execution_error', {
|
||||
detail: {
|
||||
prompt_id: jobId,
|
||||
node_id: '1',
|
||||
node_type: 'TestNode',
|
||||
exception_message: 'fail',
|
||||
exception_type: 'Error',
|
||||
traceback: []
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function callStoreJob(jobId: string, workflowPath: string) {
|
||||
store.storeJob({
|
||||
nodes: ['1'],
|
||||
id: jobId,
|
||||
workflow: { path: workflowPath } as Parameters<
|
||||
typeof store.storeJob
|
||||
>[0]['workflow']
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiEventHandlers.clear()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
})
|
||||
|
||||
it('sets running on execution_start when path mapping exists', () => {
|
||||
callStoreJob('job-1', '/workflows/a.json')
|
||||
fireExecutionStart('job-1')
|
||||
|
||||
expect(store.workflowStatus.get('/workflows/a.json')).toBe('running')
|
||||
})
|
||||
|
||||
it('sets running via ensureSessionWorkflowPath when WS fires before HTTP', () => {
|
||||
// WS fires first — no path mapping yet, setWorkflowStatus no-ops
|
||||
fireExecutionStart('job-1')
|
||||
expect(store.workflowStatus.get('/workflows/a.json')).toBeUndefined()
|
||||
|
||||
// HTTP response arrives — ensureSessionWorkflowPath sees activeJobId match
|
||||
callStoreJob('job-1', '/workflows/a.json')
|
||||
expect(store.workflowStatus.get('/workflows/a.json')).toBe('running')
|
||||
})
|
||||
|
||||
it('does not set running when job already completed before HTTP arrives', () => {
|
||||
// WS: start and success both fire before HTTP response
|
||||
fireExecutionStart('job-1')
|
||||
fireExecutionSuccess('job-1')
|
||||
|
||||
// HTTP arrives late — job is no longer active
|
||||
callStoreJob('job-1', '/workflows/a.json')
|
||||
expect(store.workflowStatus.get('/workflows/a.json')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets completed on execution_success', () => {
|
||||
callStoreJob('job-1', '/workflows/a.json')
|
||||
fireExecutionStart('job-1')
|
||||
fireExecutionSuccess('job-1')
|
||||
|
||||
expect(store.workflowStatus.get('/workflows/a.json')).toBe('completed')
|
||||
})
|
||||
|
||||
it('sets failed on execution_error', () => {
|
||||
callStoreJob('job-1', '/workflows/a.json')
|
||||
fireExecutionStart('job-1')
|
||||
fireExecutionError('job-1')
|
||||
|
||||
expect(store.workflowStatus.get('/workflows/a.json')).toBe('failed')
|
||||
})
|
||||
|
||||
it('clearWorkflowStatus removes the entry', () => {
|
||||
callStoreJob('job-1', '/workflows/a.json')
|
||||
fireExecutionStart('job-1')
|
||||
fireExecutionSuccess('job-1')
|
||||
expect(store.workflowStatus.get('/workflows/a.json')).toBe('completed')
|
||||
|
||||
store.clearWorkflowStatus('/workflows/a.json')
|
||||
expect(store.workflowStatus.has('/workflows/a.json')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
let store: ReturnType<typeof useExecutionErrorStore>
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ interface QueuedJob {
|
||||
*/
|
||||
export const MAX_PROGRESS_JOBS = 1000
|
||||
|
||||
export type WorkflowStatus = 'running' | 'completed' | 'failed'
|
||||
|
||||
export const useExecutionStore = defineStore('execution', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -80,6 +82,27 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
const initializingJobIds = ref<Set<string>>(new Set())
|
||||
|
||||
/**
|
||||
* Per-workflow-path execution status for the current session.
|
||||
* Updated by WebSocket event handlers; cleared by the UI when viewed.
|
||||
*/
|
||||
const workflowStatus = shallowRef<Map<string, WorkflowStatus>>(new Map())
|
||||
|
||||
function setWorkflowStatus(jobId: string, status: WorkflowStatus) {
|
||||
const path = jobIdToSessionWorkflowPath.value.get(jobId)
|
||||
if (!path) return
|
||||
const next = new Map(workflowStatus.value)
|
||||
next.set(path, status)
|
||||
workflowStatus.value = next
|
||||
}
|
||||
|
||||
function clearWorkflowStatus(path: string) {
|
||||
if (!workflowStatus.value.has(path)) return
|
||||
const next = new Map(workflowStatus.value)
|
||||
next.delete(path)
|
||||
workflowStatus.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for executionIdToNodeLocatorId lookups.
|
||||
* Avoids redundant graph traversals during a single execution run.
|
||||
@@ -257,6 +280,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const path = queuedJobs.value[activeJobId.value]?.workflow?.path
|
||||
if (path) ensureSessionWorkflowPath(activeJobId.value, path)
|
||||
}
|
||||
setWorkflowStatus(activeJobId.value, 'running')
|
||||
}
|
||||
|
||||
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
|
||||
@@ -270,6 +294,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
e: CustomEvent<ExecutionInterruptedWsMessage>
|
||||
) {
|
||||
const jobId = e.detail.prompt_id
|
||||
setWorkflowStatus(jobId, 'failed')
|
||||
if (activeJobId.value) clearInitializationByJobId(activeJobId.value)
|
||||
resetExecutionState(jobId)
|
||||
}
|
||||
@@ -286,6 +311,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
})
|
||||
}
|
||||
const jobId = e.detail.prompt_id
|
||||
setWorkflowStatus(jobId, 'completed')
|
||||
resetExecutionState(jobId)
|
||||
}
|
||||
|
||||
@@ -383,6 +409,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleExecutionError(e: CustomEvent<ExecutionErrorWsMessage>) {
|
||||
setWorkflowStatus(e.detail.prompt_id, 'failed')
|
||||
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackExecutionError({
|
||||
jobId: e.detail.prompt_id,
|
||||
@@ -574,6 +602,15 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
else break
|
||||
}
|
||||
jobIdToSessionWorkflowPath.value = next
|
||||
|
||||
// If this job is still executing, mark the workflow as running.
|
||||
// Handles the race where handleExecutionStart fired before the path
|
||||
// mapping existed (WebSocket arrived before the HTTP response).
|
||||
if (activeJobId.value === jobId && !workflowStatus.value.has(path)) {
|
||||
const nextStatus = new Map(workflowStatus.value)
|
||||
nextStatus.set(path, 'running')
|
||||
workflowStatus.value = nextStatus
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -652,6 +689,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
nodeLocatorIdToExecutionId,
|
||||
jobIdToWorkflowId,
|
||||
jobIdToSessionWorkflowPath,
|
||||
ensureSessionWorkflowPath
|
||||
ensureSessionWorkflowPath,
|
||||
workflowStatus,
|
||||
clearWorkflowStatus
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user