mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
add tab status indicator (running/done/errored)
This commit is contained in:
208
src/components/topbar/WorkflowTab.test.ts
Normal file
208
src/components/topbar/WorkflowTab.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
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: [] }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
closeWorkflow: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
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', 'success', 'error'] 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', 'success']
|
||||
])
|
||||
|
||||
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', 'success']
|
||||
])
|
||||
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -21,8 +21,26 @@
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<i
|
||||
v-if="jobState"
|
||||
aria-hidden="true"
|
||||
data-testid="job-state-indicator"
|
||||
:data-state="jobState"
|
||||
: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 === 'success' &&
|
||||
'icon-[lucide--circle-check] text-success-background',
|
||||
jobState === 'error' &&
|
||||
'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,17 @@ 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
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -53,6 +53,8 @@ interface QueuedJob {
|
||||
*/
|
||||
export const MAX_PROGRESS_JOBS = 1000
|
||||
|
||||
export type WorkflowStatus = 'running' | 'success' | 'error'
|
||||
|
||||
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, 'error')
|
||||
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, 'success')
|
||||
resetExecutionState(jobId)
|
||||
}
|
||||
|
||||
@@ -383,6 +409,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleExecutionError(e: CustomEvent<ExecutionErrorWsMessage>) {
|
||||
setWorkflowStatus(e.detail.prompt_id, 'error')
|
||||
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackExecutionError({
|
||||
jobId: e.detail.prompt_id,
|
||||
@@ -652,6 +680,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
nodeLocatorIdToExecutionId,
|
||||
jobIdToWorkflowId,
|
||||
jobIdToSessionWorkflowPath,
|
||||
ensureSessionWorkflowPath
|
||||
ensureSessionWorkflowPath,
|
||||
workflowStatus,
|
||||
clearWorkflowStatus
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user