Compare commits

...

7 Commits

Author SHA1 Message Date
pythongosssss
9bb103ff2e update to test library 2026-04-21 06:34:57 -07:00
pythongosssss
31f69da028 Merge remote-tracking branch 'origin/main' into pysssss/tab-status-indicator
# Conflicts:
#	src/stores/executionStore.test.ts
2026-04-20 16:39:39 -07:00
pythongosssss
7526a2902a - move cleanup to watchers, drop per tab close cleanup
- extract mutate util, refactor keys to prevent duplication
- improve test patterns, add interrupt test
2026-04-20 15:14:07 -07:00
pythongosssss
89e634d431 clear workflow state on close 2026-03-17 09:57:24 -07:00
pythongosssss
49e052ef97 rename statuses, add aria label 2026-03-17 09:33:19 -07:00
pythongosssss
83eed12c0e fix race with socket event 2026-03-17 06:43:50 -07:00
pythongosssss
e2d96e05f9 add tab status indicator (running/done/errored) 2026-03-17 06:28:27 -07:00
4 changed files with 489 additions and 11 deletions

View File

@@ -0,0 +1,202 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { ComponentProps } from 'vue-component-type-helpers'
import type { WorkflowStatus } from '@/stores/executionStore'
const { mockWorkflowStatus, mockCloseWorkflow } = await vi.hoisted(async () => {
const { shallowRef } = await import('vue')
return {
mockWorkflowStatus: shallowRef<Map<string, WorkflowStatus>>(new Map()),
mockCloseWorkflow: vi.fn().mockResolvedValue(true)
}
})
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
currentUser: null,
isAuthenticated: false,
isLoading: false
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
currentUser: null,
isAuthenticated: false,
isInitialized: true
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
get workflowStatus() {
return mockWorkflowStatus.value
}
})
}))
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: mockCloseWorkflow
})
}))
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
getThumbnail: vi.fn(() => null)
})
}))
vi.mock('./WorkflowTabPopover.vue', () => ({
default: {
render: () => null,
methods: {
showPopover: () => {},
hidePopover: () => {},
togglePopover: () => {}
}
}
}))
import WorkflowTab from './WorkflowTab.vue'
type WorkflowTabProps = ComponentProps<typeof WorkflowTab>
const statusAriaLabels: Record<WorkflowStatus, string> = {
running: 'Running',
completed: 'Completed',
failed: 'Failed'
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { close: 'Close', ...statusAriaLabels }
}
}
})
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 renderTab({
workflowOverrides = {},
activeWorkflowKey = 'other-key'
} = {}) {
return render(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
}
})
}
describe('WorkflowTab - job state indicator', () => {
beforeEach(() => {
mockWorkflowStatus.value = new Map()
mockCloseWorkflow.mockClear()
})
it.for(['running', 'completed', 'failed'] as const)(
'shows %s indicator from store with translated aria-label',
(status) => {
mockWorkflowStatus.value = new Map([['/workflows/test.json', status]])
renderTab()
const indicator = screen.getByTestId('job-state-indicator')
expect(indicator.getAttribute('data-state')).toBe(status)
expect(indicator.getAttribute('role')).toBe('status')
expect(indicator.getAttribute('aria-label')).toBe(
statusAriaLabels[status]
)
}
)
it('shows unsaved dot when no job state and workflow is unsaved', () => {
renderTab({ workflowOverrides: { isPersisted: false } })
expect(screen.queryByTestId('job-state-indicator')).toBeNull()
const dot = screen.getByTestId('unsaved-indicator')
expect(dot.textContent).toBe('•')
})
it('does not show job indicator on active tab', () => {
mockWorkflowStatus.value = new Map([['/workflows/test.json', 'completed']])
renderTab({ activeWorkflowKey: 'test-key' })
expect(screen.queryByTestId('job-state-indicator')).toBeNull()
})
it('job state replaces unsaved dot', () => {
mockWorkflowStatus.value = new Map([['/workflows/test.json', 'running']])
renderTab({ workflowOverrides: { isPersisted: false } })
const indicator = screen.getByTestId('job-state-indicator')
expect(indicator.getAttribute('data-state')).toBe('running')
})
it('delegates close to workflow service with the tab workflow', async () => {
renderTab()
const user = userEvent.setup()
await user.click(screen.getByTestId('close-workflow-button'))
expect(mockCloseWorkflow).toHaveBeenCalledWith(
expect.objectContaining({ key: 'test-key' }),
expect.anything()
)
})
})

View File

@@ -21,8 +21,27 @@
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
<i
v-if="jobState"
data-testid="job-state-indicator"
role="status"
: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
>
@@ -31,6 +50,7 @@
variant="muted-textonly"
size="icon-sm"
:aria-label="t('g.close')"
data-testid="close-workflow-button"
@click.stop="onCloseWorkflow(workflowOption)"
>
<i class="pi pi-times" />
@@ -84,8 +104,11 @@ 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 type { WorkflowStatus } from '@/stores/executionStore'
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 +135,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 +183,22 @@ 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 jobStateI18nKeys: Record<WorkflowStatus, string> = {
running: 'g.running',
completed: 'g.completed',
failed: 'g.failed'
}
const jobStateLabel = computed(() =>
jobState.value ? t(jobStateI18nKeys[jobState.value]) : undefined
)
const thumbnailUrl = computed(() => {
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
})

View File

@@ -1,23 +1,30 @@
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { app } from '@/scripts/app'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
// Create mock functions that will be shared
const {
mockNodeExecutionIdToNodeLocatorId,
mockNodeIdToNodeLocatorId,
mockNodeLocatorIdToNodeExecutionId,
mockActiveWorkflow,
mockOpenWorkflows,
mockShowTextPreview
} = vi.hoisted(() => ({
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
mockNodeIdToNodeLocatorId: vi.fn(),
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
mockShowTextPreview: vi.fn()
}))
} = await vi.hoisted(async () => {
const { ref } = await import('vue')
return {
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
mockNodeIdToNodeLocatorId: vi.fn(),
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
mockActiveWorkflow: ref<{ path?: string } | null>(null),
mockOpenWorkflows: ref<{ path: string }[]>([]),
mockShowTextPreview: vi.fn()
}
})
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type { NodeProgressState } from '@/schemas/apiSchema'
@@ -35,7 +42,13 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
useWorkflowStore: vi.fn(() => ({
nodeExecutionIdToNodeLocatorId: mockNodeExecutionIdToNodeLocatorId,
nodeIdToNodeLocatorId: mockNodeIdToNodeLocatorId,
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId,
get activeWorkflow() {
return mockActiveWorkflow.value
},
get openWorkflows() {
return mockOpenWorkflows.value
}
}))
}
})
@@ -440,6 +453,165 @@ 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 fireExecutionInterrupted(jobId: string) {
const handler = apiEventHandlers.get('execution_interrupted')
if (!handler) throw new Error('execution_interrupted handler not bound')
handler(
new CustomEvent('execution_interrupted', {
detail: { prompt_id: jobId }
})
)
}
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()
mockActiveWorkflow.value = null
mockOpenWorkflows.value = []
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('does not set running when job was interrupted before HTTP arrives', () => {
fireExecutionStart('job-1')
fireExecutionInterrupted('job-1')
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('sets failed on execution_interrupted', () => {
callStoreJob('job-1', '/workflows/a.json')
fireExecutionStart('job-1')
fireExecutionInterrupted('job-1')
expect(store.workflowStatus.get('/workflows/a.json')).toBe('failed')
})
it('ignores status events for unknown prompt ids', () => {
fireExecutionSuccess('unknown-job')
expect(store.workflowStatus.size).toBe(0)
})
it('clears only the active workflow status, leaving others intact', async () => {
callStoreJob('job-a', '/workflows/a.json')
callStoreJob('job-b', '/workflows/b.json')
fireExecutionStart('job-a')
fireExecutionSuccess('job-a')
fireExecutionStart('job-b')
fireExecutionSuccess('job-b')
mockActiveWorkflow.value = { path: '/workflows/a.json' }
await nextTick()
expect(store.workflowStatus.has('/workflows/a.json')).toBe(false)
expect(store.workflowStatus.get('/workflows/b.json')).toBe('completed')
})
it('prunes only closed workflows, leaving open ones intact', async () => {
callStoreJob('job-a', '/workflows/a.json')
callStoreJob('job-b', '/workflows/b.json')
fireExecutionSuccess('job-a')
fireExecutionSuccess('job-b')
mockOpenWorkflows.value = [{ path: '/workflows/b.json' }]
await nextTick()
expect(store.workflowStatus.has('/workflows/a.json')).toBe(false)
expect(store.workflowStatus.get('/workflows/b.json')).toBe('completed')
})
})
describe('useExecutionStore - progress_text startup guard', () => {
let store: ReturnType<typeof useExecutionStore>

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import { isCloud } from '@/platform/distribution/types'
@@ -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,53 @@ 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 automatically when the
* workflow becomes active or is closed.
*/
const workflowStatus = shallowRef<Map<string, WorkflowStatus>>(new Map())
function mutateStatus(mutator: (map: Map<string, WorkflowStatus>) => void) {
const next = new Map(workflowStatus.value)
mutator(next)
workflowStatus.value = next
}
function setWorkflowStatus(jobId: string, status: WorkflowStatus) {
const path = jobIdToSessionWorkflowPath.value.get(jobId)
if (!path) return
mutateStatus((m) => m.set(path, status))
}
function clearWorkflowStatus(path: string) {
if (!workflowStatus.value.has(path)) return
mutateStatus((m) => m.delete(path))
}
// Clear status when a workflow becomes active — the user has seen it.
watch(
() => workflowStore.activeWorkflow?.path,
(path) => {
if (path) clearWorkflowStatus(path)
}
)
// Prune statuses for workflows that have been closed.
watch(
() => workflowStore.openWorkflows,
(openWorkflows) => {
if (workflowStatus.value.size === 0) return
const openPaths = new Set(openWorkflows.map((w) => w.path))
const filtered = new Map(
[...workflowStatus.value].filter(([path]) => openPaths.has(path))
)
if (filtered.size !== workflowStatus.value.size) {
workflowStatus.value = filtered
}
}
)
/**
* Cache for executionIdToNodeLocatorId lookups.
* Avoids redundant graph traversals during a single execution run.
@@ -257,6 +306,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 +320,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 +337,7 @@ export const useExecutionStore = defineStore('execution', () => {
})
}
const jobId = e.detail.prompt_id
setWorkflowStatus(jobId, 'completed')
resetExecutionState(jobId)
}
@@ -383,6 +435,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 +628,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).
// The `has(path)` guard prevents overwriting a terminal status written
// by a late-arriving WS event for a job that already completed.
if (activeJobId.value === jobId && !workflowStatus.value.has(path)) {
mutateStatus((m) => m.set(path, 'running'))
}
}
/**
@@ -652,6 +715,7 @@ export const useExecutionStore = defineStore('execution', () => {
nodeLocatorIdToExecutionId,
jobIdToWorkflowId,
jobIdToSessionWorkflowPath,
ensureSessionWorkflowPath
ensureSessionWorkflowPath,
workflowStatus
}
})