Compare commits

...

10 Commits

Author SHA1 Message Date
pythongosssss
1288febdde test: dismiss error dialog before asserting failed tab badge
A node execution_error opens a focus-trapped error dialog that aria-hides
the rest of the app, removing the workflow tab from the accessibility tree
so getByRole can't find its status badge. Press Escape to dismiss the
dialog first, then assert the failed badge by role like the other states.
2026-06-08 08:19:28 -07:00
pythongosssss
f4c9b9c893 Merge remote-tracking branch 'origin/main' into pysssss/tab-status-indicator
# Conflicts:
#	src/components/topbar/WorkflowTab.vue
#	src/stores/executionStore.test.ts
2026-06-08 05:52:02 -07:00
pythongosssss
78a28b73bf pr feedback: harden status tracking
- track by workflow instead of mutable path
- buffer statuses and flush on job attach
- reclassify interrupted and cloud validation as non-error
- add e2e tests
2026-06-08 03:27:59 -07:00
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
6 changed files with 902 additions and 16 deletions

View File

@@ -231,6 +231,22 @@ export class ExecutionHelper {
)
}
/** Send `execution_interrupted` WS event (user-initiated stop). */
executionInterrupted(jobId: string, nodeId: string): void {
this.requireWs().send(
JSON.stringify({
type: 'execution_interrupted',
data: {
prompt_id: jobId,
timestamp: Date.now(),
node_id: nodeId,
node_type: 'Unknown',
executed: []
}
})
)
}
/** Send `progress` WS event. */
progress(jobId: string, nodeId: string, value: number, max: number): void {
this.requireWs().send(

View File

@@ -0,0 +1,139 @@
import type { Locator, WebSocketRoute } from '@playwright/test'
import { mergeTests } from '@playwright/test'
import {
comfyPageFixture,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
import { TestIds } from '@e2e/fixtures/selectors'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const KSAMPLER_NODE = '3'
async function runOnBackgroundTab(
comfyPage: ComfyPage,
ws: WebSocketRoute
): Promise<{ exec: ExecutionHelper; jobId: string; backgroundTab: Locator }> {
const topbar = comfyPage.menu.topbar
await comfyPage.workflow.waitForActiveWorkflow()
await comfyPage.workflow.waitForWorkflowIdle()
const exec = new ExecutionHelper(comfyPage, ws)
const jobId = await exec.run()
await comfyPage.nextFrame()
await topbar.newWorkflowButton.click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect(topbar.getActiveTab()).toContainText('(2)')
const backgroundTab = topbar.getTab(0)
exec.executionStart(jobId)
await expect(
backgroundTab.getByRole('img', { name: 'Running' })
).toBeVisible()
return { exec, jobId, backgroundTab }
}
test.describe('Workflow tab status indicator', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.setup()
})
test('replaces the running indicator with completed when the job finishes', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionSuccess(jobId)
await expect(
backgroundTab.getByRole('img', { name: 'Completed' })
).toBeVisible()
await expect(
backgroundTab.getByRole('img', { name: 'Running' })
).toHaveCount(0)
})
test('shows failed when the background job errors', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionError(jobId, KSAMPLER_NODE, 'boom')
// The error opens a modal dialog that aria-hides the rest of the app
// (focus trap), taking the tab out of the accessibility tree. Dismiss it
// so the badge is reachable by role.
const errorDialog = comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
await expect(errorDialog).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(errorDialog).toBeHidden()
await expect(
backgroundTab.getByRole('img', { name: 'Failed' })
).toBeVisible()
})
test('drops the indicator on user interrupt rather than showing an error', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionInterrupted(jobId, KSAMPLER_NODE)
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
})
test('clears the indicator once the tab is activated', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionSuccess(jobId)
await expect(
backgroundTab.getByRole('img', { name: 'Completed' })
).toBeVisible()
const currentTab = comfyPage.menu.topbar.getActiveTab()
await expect(
backgroundTab.getByRole('img', { name: 'Completed' })
).toBeVisible()
await backgroundTab.click()
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
await currentTab.click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
})
})

View File

@@ -0,0 +1,225 @@
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 { markRaw } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComponentProps } from 'vue-component-type-helpers'
import type * as ExecutionStoreModule from '@/stores/executionStore'
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
const { mockWorkflowStatus, mockCloseWorkflow } = await vi.hoisted(async () => {
const { shallowRef } = await import('vue')
return {
mockWorkflowStatus: shallowRef<Map<object, WorkflowExecutionStatus>>(
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', async (importOriginal) => {
const actual = await importOriginal<typeof ExecutionStoreModule>()
return {
WORKFLOW_STATUS_I18N_KEYS: actual.WORKFLOW_STATUS_I18N_KEYS,
useExecutionStore: () => ({
getWorkflowStatus(workflow: object | undefined | null) {
if (!workflow) return undefined
return mockWorkflowStatus.value.get(workflow)
}
})
}
})
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<WorkflowExecutionStatus, string> = {
running: 'Running',
completed: 'Completed',
failed: 'Failed'
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { close: 'Close', ...statusAriaLabels }
}
}
})
type WorkflowOption = WorkflowTabProps['workflowOption']
type Workflow = WorkflowOption['workflow']
type WorkflowOverrides = Partial<Workflow>
// ComfyWorkflow has many required fields the component never reads (file
// IO, change tracking). Validate the fields we *do* set against the real
// type via Partial<Workflow>, then cast — adding/renaming a read field in
// the component will fail typecheck on the override map.
function makeWorkflowOption(overrides: WorkflowOverrides = {}): WorkflowOption {
const workflow = {
key: 'test-key',
path: '/workflows/test.json',
filename: 'test.json',
isPersisted: true,
isModified: false,
activeMode: 'graph',
changeTracker: null,
...overrides
} satisfies WorkflowOverrides
// markRaw keeps a stable identity through prop reactivity so the store's
// identity-based status lookup resolves against the same object.
return { value: 'test-key', workflow: markRaw(workflow) as Workflow }
}
function renderTab({
workflowOption = makeWorkflowOption(),
activeWorkflowKey = 'other-key'
}: {
workflowOption?: WorkflowOption
activeWorkflowKey?: string
} = {}) {
return render(WorkflowTab, {
global: {
plugins: [
createTestingPinia({
stubActions: false,
initialState: {
workspace: { shiftDown: false },
workflow: {
activeWorkflow: { key: activeWorkflowKey }
},
setting: { settingValues: { 'Comfy.Workflow.AutoSave': 'off' } }
}
}),
i18n
],
stubs: {
WorkflowActionsList: true,
Button: {
template: '<button v-bind="$attrs"><slot /></button>'
}
}
},
props: {
workflowOption,
isFirst: false,
isLast: false
}
})
}
describe('WorkflowTab - workflow status indicator', () => {
beforeEach(() => {
mockWorkflowStatus.value = new Map()
})
it.for(['running', 'completed', 'failed'] as const)(
'labels the %s indicator with a translated status name',
(status) => {
const workflowOption = makeWorkflowOption()
mockWorkflowStatus.value = new Map([[workflowOption.workflow, status]])
renderTab({ workflowOption })
expect(
screen.getByRole('img', { name: statusAriaLabels[status] })
).toBeTruthy()
}
)
it('shows unsaved dot when no workflow status and workflow is unsaved', () => {
renderTab({ workflowOption: makeWorkflowOption({ isPersisted: false }) })
expect(screen.queryByRole('img')).toBeNull()
expect(screen.getByTestId('workflow-dirty-indicator').textContent).toBe('•')
})
it('shows the unsaved dot when modified and autosave is off', () => {
renderTab({ workflowOption: makeWorkflowOption({ isModified: true }) })
expect(screen.getByTestId('workflow-dirty-indicator').textContent).toBe('•')
})
it('workflow status replaces the unsaved dot', () => {
const workflowOption = makeWorkflowOption({ isPersisted: false })
mockWorkflowStatus.value = new Map([[workflowOption.workflow, 'running']])
renderTab({ workflowOption })
expect(
screen.getByRole('img', { name: statusAriaLabels.running })
).toBeTruthy()
expect(screen.queryByTestId('workflow-dirty-indicator')).toBeNull()
})
})
describe('WorkflowTab - close button', () => {
beforeEach(() => {
mockCloseWorkflow.mockClear()
})
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,19 @@
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
<i
v-if="workflowStatus"
role="img"
:aria-label="workflowStatusLabel"
:class="
cn(
'absolute top-1/2 left-1/2 z-10 size-4 -translate-1/2 group-hover:hidden',
workflowStatusIconClasses[workflowStatus]
)
"
/>
<span
v-if="shouldShowStatusIndicator"
v-else-if="shouldShowUnsavedIndicator"
data-testid="workflow-dirty-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
@@ -32,6 +43,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" />
@@ -85,8 +97,14 @@ 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 { WorkflowExecutionStatus } from '@/stores/executionStore'
import {
useExecutionStore,
WORKFLOW_STATUS_I18N_KEYS
} from '@/stores/executionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
import { cn } from '@comfyorg/tailwind-utils'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
@@ -113,6 +131,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()
@@ -125,7 +144,7 @@ const autoSaveDelay = computed(() =>
settingStore.get('Comfy.Workflow.AutoSaveDelay')
)
const shouldShowStatusIndicator = computed(() => {
const shouldShowUnsavedIndicator = computed(() => {
if (workspaceStore.shiftDown) {
// Branch 1: Shift key is held down, do not show the status indicator.
return false
@@ -160,6 +179,23 @@ const isActiveTab = computed(() => {
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
})
const workflowStatusIconClasses: Record<WorkflowExecutionStatus, string> = {
running:
'text-base-foreground icon-[lucide--loader-circle] motion-safe:animate-spin',
completed: 'icon-[lucide--circle-check] text-success-background',
failed: 'icon-[lucide--octagon-alert] text-destructive-background'
}
const workflowStatus = computed(() =>
executionStore.getWorkflowStatus(props.workflowOption.workflow)
)
const workflowStatusLabel = computed(() =>
workflowStatus.value
? t(WORKFLOW_STATUS_I18N_KEYS[workflowStatus.value])
: undefined
)
const thumbnailUrl = computed(() => {
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
})

View File

@@ -1,5 +1,6 @@
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'
@@ -10,18 +11,24 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type { NodeProgressState } from '@/schemas/apiSchema'
// 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 { shallowRef } = await import('vue')
return {
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
mockNodeIdToNodeLocatorId: vi.fn(),
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
mockActiveWorkflow: shallowRef<{ path?: string } | null>(null),
mockOpenWorkflows: shallowRef<{ path: string }[]>([]),
mockShowTextPreview: vi.fn()
}
})
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
@@ -35,7 +42,15 @@ 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
},
isOpen: (workflow: { path?: string }) =>
mockOpenWorkflows.value.some((w) => w.path === workflow.path)
}))
}
})
@@ -94,6 +109,11 @@ vi.mock('@/scripts/app', () => ({
}
}))
beforeEach(() => {
mockActiveWorkflow.value = null
mockOpenWorkflows.value = []
})
function createQueuedWorkflow(path: string = 'workflows/test.json') {
return {
activeState: { id: 'workflow-id' },
@@ -460,6 +480,319 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
})
})
describe('useExecutionStore - workflowStatus', () => {
let store: ReturnType<typeof useExecutionStore>
type Workflow = Parameters<typeof store.storeJob>[0]['workflow']
const makeWorkflow = (path: string): Workflow => {
const workflow: Partial<Workflow> = {
path,
filename: path.split('/').pop()
}
return workflow as Workflow
}
const workflowA = makeWorkflow('/workflows/a.json')
const workflowB = makeWorkflow('/workflows/b.json')
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, workflow: Workflow) {
store.storeJob({
nodes: ['1'],
id: jobId,
promptOutput: { '1': createPromptNode('Node', 'TestNode') },
workflow
})
}
beforeEach(() => {
vi.clearAllMocks()
apiEventHandlers.clear()
mockOpenWorkflows.value = [workflowA, workflowB]
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
store.bindExecutionEvents()
})
it('sets running on execution_start when storeJob already ran', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('running')
})
it('flushes running status when storeJob arrives after WS', () => {
fireExecutionStart('job-1')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBe('running')
})
it('flushes terminal completed when WS finishes before storeJob', () => {
// Instant-finish race: WS fires start+success before HTTP response.
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
})
it('flushes terminal failed when WS errors before storeJob', () => {
// Invalid-workflow path: execution_error fires before HTTP response.
fireExecutionError('job-1')
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBe('failed')
})
it('drops pending status on interrupt before storeJob', () => {
fireExecutionStart('job-1')
fireExecutionInterrupted('job-1')
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('sets completed on execution_success', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
})
it('sets failed on execution_error', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionError('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('failed')
})
it('skips status badge on user-initiated interrupt', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionInterrupted('job-1')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('evicts the oldest pending status once the buffer cap is exceeded', () => {
// Each start with no matching storeJob buffers a 'running' status. One
// past the cap evicts the oldest so the buffer can't grow unbounded.
for (let i = 0; i <= MAX_PROGRESS_JOBS; i++) fireExecutionStart(`job-${i}`)
callStoreJob('job-0', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
callStoreJob(`job-${MAX_PROGRESS_JOBS}`, workflowB)
expect(store.getWorkflowStatus(workflowB)).toBe('running')
})
it('does not report status for the active workflow', () => {
mockActiveWorkflow.value = workflowA
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('keeps running visible after the active tab is viewed then left', async () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
mockActiveWorkflow.value = workflowA
await nextTick()
mockActiveWorkflow.value = workflowB
await nextTick()
expect(store.getWorkflowStatus(workflowA)).toBe('running')
})
it('overwrites stale terminal with running on re-queue', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
// Re-queue the same workflow under a fresh jobId.
callStoreJob('job-2', workflowA)
fireExecutionStart('job-2')
expect(store.getWorkflowStatus(workflowA)).toBe('running')
})
it('ignores status events for unknown prompt ids', () => {
fireExecutionSuccess('unknown-job')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
expect(store.getWorkflowStatus(workflowB)).toBeUndefined()
})
it.for(['running', 'completed', 'failed'] as const)(
'hides %s status while the workflow is the active tab',
async (status) => {
callStoreJob('job-a', workflowA)
fireExecutionStart('job-a')
if (status === 'completed') fireExecutionSuccess('job-a')
else if (status === 'failed') fireExecutionError('job-a')
callStoreJob('job-b', workflowB)
fireExecutionStart('job-b')
fireExecutionSuccess('job-b')
expect(store.getWorkflowStatus(workflowA)).toBe(status)
mockActiveWorkflow.value = workflowA
await nextTick()
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
expect(store.getWorkflowStatus(workflowB)).toBe('completed')
}
)
it('does not badge a workflow that completes while it is active', async () => {
mockActiveWorkflow.value = workflowA
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
mockActiveWorkflow.value = workflowB
await nextTick()
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('clears a terminal badge once its tab has been viewed', async () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
mockActiveWorkflow.value = workflowA
await nextTick()
mockActiveWorkflow.value = workflowB
await nextTick()
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('prunes only closed workflows, leaving open ones intact', async () => {
callStoreJob('job-a', workflowA)
callStoreJob('job-b', workflowB)
fireExecutionSuccess('job-a')
fireExecutionSuccess('job-b')
mockOpenWorkflows.value = [workflowB]
await nextTick()
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
expect(store.getWorkflowStatus(workflowB)).toBe('completed')
})
it('ignores terminal events for a workflow closed mid-run', async () => {
callStoreJob('job-a', workflowA)
fireExecutionStart('job-a')
expect(store.getWorkflowStatus(workflowA)).toBe('running')
// Close the tab while the job is still running.
mockOpenWorkflows.value = [workflowB]
await nextTick()
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
// A late success must not resurrect an entry for the closed workflow.
fireExecutionSuccess('job-a')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('drops service-level errors without writing failed', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('running')
// Service-level error: empty node_id triggers the short-circuit branch.
const handler = apiEventHandlers.get('execution_error')
handler!(
new CustomEvent('execution_error', {
detail: {
prompt_id: 'job-1',
node_id: '',
node_type: '',
exception_message: 'Job has stagnated',
exception_type: 'StagnationError',
traceback: []
}
})
)
expect(store.getWorkflowStatus(workflowA)).toBe('running')
})
it('drops pending failed when service-level error fires before storeJob', () => {
apiEventHandlers.get('execution_error')!(
new CustomEvent('execution_error', {
detail: {
prompt_id: 'job-1',
node_id: '',
node_type: '',
exception_message: 'Job has stagnated',
exception_type: 'StagnationError',
traceback: []
}
})
)
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('clears workflowStatus on unbindExecutionEvents', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
store.unbindExecutionEvents()
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
})
describe('useExecutionStore - clearActiveJobIfStale', () => {
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'
@@ -78,6 +78,17 @@ function buildExecutionNodeLookup(
*/
export const MAX_PROGRESS_JOBS = 1000
export type WorkflowExecutionStatus = 'running' | 'completed' | 'failed'
export const WORKFLOW_STATUS_I18N_KEYS: Record<
WorkflowExecutionStatus,
string
> = {
running: 'g.running',
completed: 'g.completed',
failed: 'g.failed'
}
export const useExecutionStore = defineStore('execution', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
@@ -105,6 +116,100 @@ export const useExecutionStore = defineStore('execution', () => {
const initializingJobIds = ref<Set<JobId>>(new Set())
const workflowStatus = shallowRef<
Map<ComfyWorkflow, WorkflowExecutionStatus>
>(new Map())
const jobIdToWorkflow = new Map<string, ComfyWorkflow>()
// Buffers statuses arriving before storeJob attaches the workflow.
// FIFO-capped to bound growth if a matching storeJob never fires.
const pendingWorkflowStatusByJobId = new Map<
string,
WorkflowExecutionStatus
>()
function bufferPendingWorkflowStatus(
jobId: string,
status: WorkflowExecutionStatus
) {
pendingWorkflowStatusByJobId.delete(jobId)
pendingWorkflowStatusByJobId.set(jobId, status)
while (pendingWorkflowStatusByJobId.size > MAX_PROGRESS_JOBS) {
const oldest = pendingWorkflowStatusByJobId.keys().next().value
if (oldest === undefined) break
pendingWorkflowStatusByJobId.delete(oldest)
}
}
function mutateStatus(
mutator: (map: Map<ComfyWorkflow, WorkflowExecutionStatus>) => void
) {
const next = new Map(workflowStatus.value)
mutator(next)
workflowStatus.value = next
}
function applyWorkflowStatus(
workflow: ComfyWorkflow,
status: WorkflowExecutionStatus
) {
// A late terminal event can arrive after the tab closed; don't resurrect
// an entry (which also pins the workflow ref) for a closed workflow.
if (!workflowStore.isOpen(workflow)) return
if (status !== 'running' && workflow === workflowStore.activeWorkflow) {
clearWorkflowStatus(workflow)
return
}
mutateStatus((m) => m.set(workflow, status))
}
function setWorkflowStatus(jobId: string, status: WorkflowExecutionStatus) {
const workflow = jobIdToWorkflow.get(jobId)
if (!workflow) {
bufferPendingWorkflowStatus(jobId, status)
return
}
applyWorkflowStatus(workflow, status)
}
function clearWorkflowStatus(workflow: ComfyWorkflow) {
if (!workflowStatus.value.has(workflow)) return
mutateStatus((m) => m.delete(workflow))
}
function getWorkflowStatus(
workflow: ComfyWorkflow | undefined | null
): WorkflowExecutionStatus | undefined {
if (!workflow) return undefined
if (workflow === workflowStore.activeWorkflow) return undefined
return workflowStatus.value.get(workflow)
}
watch(
() => workflowStore.activeWorkflow,
(workflow) => {
if (workflow && workflowStatus.value.get(workflow) !== 'running') {
clearWorkflowStatus(workflow)
}
}
)
// Prune statuses for workflows that have been closed.
watch(
() => workflowStore.openWorkflows,
(openWorkflows) => {
if (workflowStatus.value.size === 0) return
const openSet = new Set(openWorkflows)
const filtered = new Map(
[...workflowStatus.value].filter(([w]) => openSet.has(w))
)
if (filtered.size !== workflowStatus.value.size) {
workflowStatus.value = filtered
}
}
)
/**
* Cache for executionIdToNodeLocatorId lookups.
* Avoids redundant graph traversals during a single execution run.
@@ -257,6 +362,10 @@ export const useExecutionStore = defineStore('execution', () => {
api.removeEventListener('status', handleStatus)
api.removeEventListener('execution_error', handleExecutionError)
api.removeEventListener('progress_text', handleProgressText)
if (workflowStatus.value.size > 0) workflowStatus.value = new Map()
pendingWorkflowStatusByJobId.clear()
jobIdToWorkflow.clear()
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
@@ -272,6 +381,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>) {
@@ -285,6 +395,10 @@ export const useExecutionStore = defineStore('execution', () => {
e: CustomEvent<ExecutionInterruptedWsMessage>
) {
const jobId = e.detail.prompt_id
// User-initiated stop is not a failure — drop the badge entirely.
pendingWorkflowStatusByJobId.delete(jobId)
const workflow = jobIdToWorkflow.get(jobId)
if (workflow) clearWorkflowStatus(workflow)
if (activeJobId.value) clearInitializationByJobId(activeJobId.value)
resetExecutionState(jobId)
}
@@ -301,6 +415,7 @@ export const useExecutionStore = defineStore('execution', () => {
})
}
const jobId = e.detail.prompt_id
setWorkflowStatus(jobId, 'completed')
resetExecutionState(jobId)
}
@@ -407,14 +522,20 @@ export const useExecutionStore = defineStore('execution', () => {
})
// Cloud wraps validation errors (400) in exception_message as embedded JSON.
if (handleCloudValidationError(e.detail)) return
// Pre-flight validation isn't a runtime failure — no badge.
if (handleCloudValidationError(e.detail)) {
pendingWorkflowStatusByJobId.delete(e.detail.prompt_id)
return
}
}
// Service-level errors (e.g. "Job has stagnated") have no associated node.
// Route them as job errors
if (handleServiceLevelError(e.detail)) return
if (handleServiceLevelError(e.detail)) {
pendingWorkflowStatusByJobId.delete(e.detail.prompt_id)
return
}
// OSS path / Cloud fallback (real runtime errors)
setWorkflowStatus(e.detail.prompt_id, 'failed')
executionErrorStore.lastExecutionError = e.detail
clearInitializationByJobId(e.detail.prompt_id)
resetExecutionState(e.detail.prompt_id)
@@ -525,6 +646,7 @@ export const useExecutionStore = defineStore('execution', () => {
delete map[jobId]
nodeProgressStatesByJob.value = map
useJobPreviewStore().clearPreview(jobId)
jobIdToWorkflow.delete(jobId)
}
if (activeJobId.value) {
delete queuedJobs.value[activeJobId.value]
@@ -580,6 +702,7 @@ export const useExecutionStore = defineStore('execution', () => {
}
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
queuedJob.workflow = workflow
if (workflow) jobIdToWorkflow.set(String(id), workflow)
const wid = workflow?.activeState?.id ?? workflow?.initialState?.id
if (wid) {
jobIdToWorkflowId.value.set(id, wid)
@@ -587,6 +710,19 @@ export const useExecutionStore = defineStore('execution', () => {
if (workflow?.path) {
ensureSessionWorkflowPath(id, workflow.path)
}
flushPendingWorkflowStatus(String(id), workflow)
}
function flushPendingWorkflowStatus(
jobId: string,
workflow: ComfyWorkflow | undefined
) {
const pending = pendingWorkflowStatusByJobId.get(jobId)
if (pending === undefined || !workflow) return
pendingWorkflowStatusByJobId.delete(jobId)
// Don't let a stale 'running' overwrite a terminal status already set.
if (pending === 'running' && workflowStatus.value.has(workflow)) return
applyWorkflowStatus(workflow, pending)
}
// ~0.65 MB at capacity (32 char GUID key + 50 char path value)
@@ -681,6 +817,7 @@ export const useExecutionStore = defineStore('execution', () => {
nodeLocatorIdToExecutionId,
jobIdToWorkflowId,
jobIdToSessionWorkflowPath,
ensureSessionWorkflowPath
ensureSessionWorkflowPath,
getWorkflowStatus
}
})