mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-23 00:04:50 +00:00
Compare commits
2 Commits
feat/ephem
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56b05c0fd5 | ||
|
|
403353ac77 |
@@ -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(
|
||||
|
||||
139
browser_tests/tests/topbar/workflowTabStatus.spec.ts
Normal file
139
browser_tests/tests/topbar/workflowTabStatus.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
233
src/components/topbar/WorkflowTab.test.ts
Normal file
233
src/components/topbar/WorkflowTab.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
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('does not badge the active tab with its own status', () => {
|
||||
const workflowOption = makeWorkflowOption()
|
||||
mockWorkflowStatus.value = new Map([[workflowOption.workflow, 'running']])
|
||||
|
||||
renderTab({ workflowOption, activeWorkflowKey: 'test-key' })
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
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()
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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,27 @@ 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'
|
||||
}
|
||||
|
||||
// The active tab doesn't badge its own status - the user is already looking
|
||||
// at it. Background tabs surface the recorded execution status.
|
||||
const workflowStatus = computed(() =>
|
||||
isActiveTab.value
|
||||
? undefined
|
||||
: 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)
|
||||
})
|
||||
|
||||
@@ -43,6 +43,10 @@ vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: { showSignInButton: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowStatusDismissal', () => ({
|
||||
useWorkflowStatusDismissal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useOverflowObserver', () => ({
|
||||
useOverflowObserver: () => ({
|
||||
isOverflowing: { value: false },
|
||||
|
||||
@@ -117,6 +117,7 @@ import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useWorkflowStatusDismissal } from '@/composables/useWorkflowStatusDismissal'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
@@ -145,6 +146,9 @@ const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const commandStore = useCommandStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
// Dismiss a tab's terminal status badge once it has been viewed
|
||||
useWorkflowStatusDismissal()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const isIntegratedTabBar = computed(
|
||||
|
||||
98
src/composables/useWorkflowStatusDismissal.test.ts
Normal file
98
src/composables/useWorkflowStatusDismissal.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, nextTick } from 'vue'
|
||||
|
||||
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
|
||||
|
||||
const { mockActiveWorkflow, statusMap } = await vi.hoisted(async () => {
|
||||
const { shallowRef } = await import('vue')
|
||||
return {
|
||||
mockActiveWorkflow: shallowRef<object | null>(null),
|
||||
statusMap: shallowRef<Map<object, WorkflowExecutionStatus>>(new Map())
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
getWorkflowStatus: (workflow: object | null | undefined) =>
|
||||
workflow ? statusMap.value.get(workflow) : undefined,
|
||||
clearWorkflowStatus: (workflow: object) => {
|
||||
const next = new Map(statusMap.value)
|
||||
next.delete(workflow)
|
||||
statusMap.value = next
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
import { useWorkflowStatusDismissal } from './useWorkflowStatusDismissal'
|
||||
|
||||
const workflowA = { path: '/a.json' }
|
||||
const workflowB = { path: '/b.json' }
|
||||
|
||||
function mount() {
|
||||
const scope = effectScope()
|
||||
scope.run(() => useWorkflowStatusDismissal())
|
||||
return () => scope.stop()
|
||||
}
|
||||
|
||||
describe('useWorkflowStatusDismissal', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkflow.value = null
|
||||
statusMap.value = new Map()
|
||||
})
|
||||
|
||||
it('clears a terminal status when its workflow becomes active', async () => {
|
||||
statusMap.value = new Map([[workflowA, 'completed']])
|
||||
const stop = mount()
|
||||
|
||||
mockActiveWorkflow.value = workflowA
|
||||
await nextTick()
|
||||
|
||||
expect(statusMap.value.has(workflowA)).toBe(false)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('clears a terminal status that arrives while the workflow is active', async () => {
|
||||
mockActiveWorkflow.value = workflowA
|
||||
const stop = mount()
|
||||
|
||||
statusMap.value = new Map([[workflowA, 'failed']])
|
||||
await nextTick()
|
||||
|
||||
expect(statusMap.value.has(workflowA)).toBe(false)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('keeps a running status on the active workflow', async () => {
|
||||
mockActiveWorkflow.value = workflowA
|
||||
const stop = mount()
|
||||
|
||||
statusMap.value = new Map([[workflowA, 'running']])
|
||||
await nextTick()
|
||||
|
||||
expect(statusMap.value.get(workflowA)).toBe('running')
|
||||
stop()
|
||||
})
|
||||
|
||||
it('leaves other workflows untouched', async () => {
|
||||
statusMap.value = new Map([
|
||||
[workflowA, 'completed'],
|
||||
[workflowB, 'completed']
|
||||
])
|
||||
const stop = mount()
|
||||
|
||||
mockActiveWorkflow.value = workflowA
|
||||
await nextTick()
|
||||
|
||||
expect(statusMap.value.has(workflowA)).toBe(false)
|
||||
expect(statusMap.value.get(workflowB)).toBe('completed')
|
||||
stop()
|
||||
})
|
||||
})
|
||||
22
src/composables/useWorkflowStatusDismissal.ts
Normal file
22
src/composables/useWorkflowStatusDismissal.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
export function useWorkflowStatusDismissal() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
(workflow) => {
|
||||
if (
|
||||
workflow &&
|
||||
executionStore.getWorkflowStatus(workflow) !== 'running'
|
||||
) {
|
||||
executionStore.clearWorkflowStatus(workflow)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from './comfyApi'
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
|
||||
describe('getComfyApiBaseUrl', () => {
|
||||
const originalConfig = remoteConfig.value
|
||||
|
||||
beforeEach(() => {
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
remoteConfig.value = originalConfig
|
||||
})
|
||||
|
||||
it('honors the server-provided override', () => {
|
||||
remoteConfig.value = { comfy_api_base_url: 'https://my-ephem.example.com' }
|
||||
expect(getComfyApiBaseUrl()).toBe('https://my-ephem.example.com')
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the key is absent', () => {
|
||||
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the value is empty', () => {
|
||||
remoteConfig.value = { comfy_api_base_url: '' }
|
||||
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getComfyPlatformBaseUrl', () => {
|
||||
const originalConfig = remoteConfig.value
|
||||
|
||||
beforeEach(() => {
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
remoteConfig.value = originalConfig
|
||||
})
|
||||
|
||||
it('honors the server-provided override', () => {
|
||||
remoteConfig.value = {
|
||||
comfy_platform_base_url: 'https://my-ephem-platform.example.com'
|
||||
}
|
||||
expect(getComfyPlatformBaseUrl()).toBe(
|
||||
'https://my-ephem-platform.example.com'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the key is absent', () => {
|
||||
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
|
||||
})
|
||||
|
||||
it('falls back to the build-time default when the value is empty', () => {
|
||||
remoteConfig.value = { comfy_platform_base_url: '' }
|
||||
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('compatibility with comfyui servers that predate the override keys', () => {
|
||||
const originalConfig = remoteConfig.value
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
remoteConfig.value = originalConfig
|
||||
})
|
||||
|
||||
it('falls back to build-time defaults when /features omits the URL keys', async () => {
|
||||
// An older comfyui server has /features but doesn't know about
|
||||
// comfy_api_base_url / comfy_platform_base_url yet.
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
supports_preview_metadata: true,
|
||||
max_upload_size: 104857600
|
||||
})
|
||||
} as Response)
|
||||
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
|
||||
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
configValueOrDefault,
|
||||
remoteConfig
|
||||
@@ -18,14 +19,11 @@ const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
|
||||
: (import.meta.env.VITE_STAGING_PLATFORM_BASE_URL ??
|
||||
STAGING_PLATFORM_BASE_URL)
|
||||
|
||||
/**
|
||||
* Resolves the ComfyUI API base URL.
|
||||
*
|
||||
* The local server (any distribution) is authoritative:
|
||||
* whatever `/api/features` returns for `comfy_api_base_url` wins, falling back to the build-time default.
|
||||
* That way the server can point its frontend at a different api host without rebuilding the frontend package.
|
||||
*/
|
||||
export function getComfyApiBaseUrl(): string {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_API_BASE_URL
|
||||
}
|
||||
|
||||
return configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
'comfy_api_base_url',
|
||||
@@ -33,11 +31,11 @@ export function getComfyApiBaseUrl(): string {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the ComfyUI Platform base URL.
|
||||
* As with the api base, the server's `/api/features` (`comfy_platform_base_url`) overrides the build-time default.
|
||||
*/
|
||||
export function getComfyPlatformBaseUrl(): string {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_PLATFORM_BASE_URL
|
||||
}
|
||||
|
||||
return configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
'comfy_platform_base_url',
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
import { getFirebaseConfig } from './firebase'
|
||||
|
||||
describe('getFirebaseConfig', () => {
|
||||
const originalConfig = remoteConfig.value
|
||||
|
||||
beforeEach(() => {
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
remoteConfig.value = originalConfig
|
||||
})
|
||||
|
||||
it('honors a full server-provided firebase_config (cloud builds)', () => {
|
||||
const cloud = {
|
||||
apiKey: 'cloud-key',
|
||||
authDomain: 'cloud.example.com',
|
||||
projectId: 'some-cloud-project',
|
||||
storageBucket: 'cloud.appspot.com',
|
||||
messagingSenderId: '1',
|
||||
appId: '1:1:web:abc'
|
||||
}
|
||||
remoteConfig.value = { firebase_config: cloud }
|
||||
expect(getFirebaseConfig()).toEqual(cloud)
|
||||
})
|
||||
|
||||
it('uses the dev project for a staging-tier api base (staging or testenv)', () => {
|
||||
// No firebase_config from the server — the dev project is derived from the
|
||||
// api base, using the DEV config bundled in the frontend.
|
||||
remoteConfig.value = { comfy_api_base_url: 'https://stagingapi.comfy.org' }
|
||||
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
|
||||
remoteConfig.value = {
|
||||
comfy_api_base_url: 'https://pr-1-registry.testenvs.comfy.org'
|
||||
}
|
||||
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
|
||||
})
|
||||
|
||||
it('falls back to the build-time config otherwise', () => {
|
||||
// The test build uses the non-prod config => dreamboothy-dev.
|
||||
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FirebaseOptions } from 'firebase/app'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
const DEV_CONFIG: FirebaseOptions = {
|
||||
@@ -26,28 +27,16 @@ const PROD_CONFIG: FirebaseOptions = {
|
||||
|
||||
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
|
||||
|
||||
const STAGING_API_HOST = 'stagingapi.comfy.org'
|
||||
const TESTENV_HOST_SUFFIX = '.testenvs.comfy.org'
|
||||
|
||||
// staging + the ephemeral testenvs use the dev Firebase project (prod uses prod)
|
||||
function isStagingTierApiBase(apiBase: string | undefined): boolean {
|
||||
if (!apiBase) return false
|
||||
try {
|
||||
const host = new URL(apiBase).hostname
|
||||
return host === STAGING_API_HOST || host.endsWith(TESTENV_HOST_SUFFIX)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Firebase config for the current backend: the server's firebase_config (cloud builds),
|
||||
* else the bundled DEV_CONFIG when the api base is staging-tier, else the build-time default.
|
||||
* Returns the Firebase configuration for the current environment.
|
||||
* - Cloud builds use runtime configuration delivered via feature flags
|
||||
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
|
||||
*/
|
||||
export function getFirebaseConfig(): FirebaseOptions {
|
||||
if (!isCloud) {
|
||||
return BUILD_TIME_CONFIG
|
||||
}
|
||||
|
||||
const runtimeConfig = remoteConfig.value.firebase_config
|
||||
if (runtimeConfig) return runtimeConfig
|
||||
if (isStagingTierApiBase(remoteConfig.value.comfy_api_base_url))
|
||||
return DEV_CONFIG
|
||||
return BUILD_TIME_CONFIG
|
||||
return runtimeConfig ?? BUILD_TIME_CONFIG
|
||||
}
|
||||
|
||||
15
src/main.ts
15
src/main.ts
@@ -30,18 +30,15 @@ import App from './App.vue'
|
||||
import './assets/css/style.css'
|
||||
import { i18n } from './i18n'
|
||||
|
||||
/**
|
||||
* CRITICAL: Load remote config FIRST so window.__CONFIG__ is available for all modules during initialization.
|
||||
* The local /api/features endpoint is the source of truth for runtime config (api base, Firebase project, …).
|
||||
* Allows the server to dictate which backend the frontend talks to and which Firebase project it logs in against.
|
||||
* Must run before initializeApp() below so getFirebaseConfig() sees it.
|
||||
*/
|
||||
const isCloud = __DISTRIBUTION__ === 'cloud'
|
||||
const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry)
|
||||
const requiresRemoteConfigBootstrap = isCloud || hasHostTelemetryBridge
|
||||
|
||||
const { refreshRemoteConfig } =
|
||||
await import('@/platform/remoteConfig/refreshRemoteConfig')
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
if (requiresRemoteConfigBootstrap) {
|
||||
const { refreshRemoteConfig } =
|
||||
await import('@/platform/remoteConfig/refreshRemoteConfig')
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
}
|
||||
|
||||
if (isCloud) {
|
||||
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import type { DialogComponentProps } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
interface ShowOptions {
|
||||
@@ -23,6 +24,10 @@ interface BrowseOptions {
|
||||
}
|
||||
|
||||
const DIALOG_KEY = 'global-asset-browser'
|
||||
const ASSET_BROWSER_DIALOG_PROPS = {
|
||||
contentClass:
|
||||
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)] border-none bg-transparent shadow-none'
|
||||
} satisfies DialogComponentProps
|
||||
|
||||
export const useAssetBrowserDialog = () => {
|
||||
const dialogService = useDialogService()
|
||||
@@ -47,7 +52,8 @@ export const useAssetBrowserDialog = () => {
|
||||
currentValue: props.currentValue,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: hide
|
||||
}
|
||||
},
|
||||
dialogComponentProps: ASSET_BROWSER_DIALOG_PROPS
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,7 +72,8 @@ export const useAssetBrowserDialog = () => {
|
||||
title: options.title,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: hide
|
||||
}
|
||||
},
|
||||
dialogComponentProps: ASSET_BROWSER_DIALOG_PROPS
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -43,10 +43,9 @@ describe('refreshRemoteConfig', () => {
|
||||
|
||||
await refreshRemoteConfig({ useAuth: true })
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/features',
|
||||
expect.objectContaining({ cache: 'no-store' })
|
||||
)
|
||||
expect(api.fetchApi).toHaveBeenCalledWith('/features', {
|
||||
cache: 'no-store'
|
||||
})
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
expect(remoteConfig.value).toEqual(mockConfig)
|
||||
expect(window.__CONFIG__).toEqual(mockConfig)
|
||||
@@ -68,38 +67,15 @@ describe('refreshRemoteConfig', () => {
|
||||
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/features',
|
||||
expect.objectContaining({ cache: 'no-store' })
|
||||
)
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/features', {
|
||||
cache: 'no-store'
|
||||
})
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
expect(remoteConfig.value).toEqual(mockConfig)
|
||||
expect(window.__CONFIG__).toEqual(mockConfig)
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeout', () => {
|
||||
it('passes an AbortSignal so a wedged /features cannot hang startup', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
const init = vi.mocked(global.fetch).mock.calls[0][1]
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
it('falls back to empty config when the request aborts', async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(
|
||||
new DOMException('Aborted', 'AbortError')
|
||||
)
|
||||
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
expect(remoteConfig.value).toEqual({})
|
||||
expect(window.__CONFIG__).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('clears config on 401 response', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue(
|
||||
|
||||
@@ -4,11 +4,6 @@ import {
|
||||
remoteConfigState
|
||||
} from './remoteConfig'
|
||||
|
||||
// Cap the bootstrap fetch so a wedged /features endpoint can never block app.mount indefinitely.
|
||||
// A same-origin GET against the local comfyui server should resolve in well under a second;
|
||||
// on timeout the catch below clears remoteConfig and consumers fall back to build-time defaults.
|
||||
const FEATURES_FETCH_TIMEOUT_MS = 5_000
|
||||
|
||||
interface RefreshRemoteConfigOptions {
|
||||
/**
|
||||
* Whether to use authenticated API (default: true).
|
||||
@@ -17,14 +12,11 @@ interface RefreshRemoteConfigOptions {
|
||||
useAuth?: boolean
|
||||
}
|
||||
|
||||
async function fetchRemoteConfig(
|
||||
useAuth: boolean,
|
||||
signal: AbortSignal
|
||||
): Promise<Response> {
|
||||
if (!useAuth) return fetch('/api/features', { cache: 'no-store', signal })
|
||||
async function fetchRemoteConfig(useAuth: boolean): Promise<Response> {
|
||||
if (!useAuth) return fetch('/api/features', { cache: 'no-store' })
|
||||
|
||||
const { api } = await import('@/scripts/api')
|
||||
return api.fetchApi('/features', { cache: 'no-store', signal })
|
||||
return api.fetchApi('/features', { cache: 'no-store' })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,14 +33,8 @@ export async function refreshRemoteConfig(
|
||||
): Promise<void> {
|
||||
const { useAuth = true } = options
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
FEATURES_FETCH_TIMEOUT_MS
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await fetchRemoteConfig(useAuth, controller.signal)
|
||||
const response = await fetchRemoteConfig(useAuth)
|
||||
|
||||
if (response.ok) {
|
||||
const config = await response.json()
|
||||
@@ -73,7 +59,5 @@ export async function refreshRemoteConfig(
|
||||
window.__CONFIG__ = {}
|
||||
remoteConfig.value = {}
|
||||
remoteConfigState.value = 'error'
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -11,24 +12,30 @@ 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,
|
||||
mockTrackExecutionError,
|
||||
mockTrackExecutionSuccess,
|
||||
mockTrackSharedWorkflowRun
|
||||
} = vi.hoisted(() => ({
|
||||
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
|
||||
mockShowTextPreview: vi.fn(),
|
||||
mockTrackExecutionError: vi.fn(),
|
||||
mockTrackExecutionSuccess: vi.fn(),
|
||||
mockTrackSharedWorkflowRun: 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(),
|
||||
mockTrackExecutionError: vi.fn(),
|
||||
mockTrackExecutionSuccess: vi.fn(),
|
||||
mockTrackSharedWorkflowRun: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
const mockAppModeState = vi.hoisted(() => ({
|
||||
mode: { value: 'graph' },
|
||||
@@ -47,7 +54,6 @@ beforeEach(() => {
|
||||
mockAppModeState.mode.value = 'graph'
|
||||
mockAppModeState.isAppMode.value = false
|
||||
})
|
||||
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
@@ -61,7 +67,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)
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -135,6 +149,11 @@ vi.mock('@/scripts/app', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mockActiveWorkflow.value = null
|
||||
mockOpenWorkflows.value = []
|
||||
})
|
||||
|
||||
function createQueuedWorkflow(path: string = 'workflows/test.json') {
|
||||
return {
|
||||
activeState: { id: 'workflow-id' },
|
||||
@@ -501,6 +520,254 @@ 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('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('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>
|
||||
|
||||
|
||||
@@ -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 { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -93,6 +93,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()
|
||||
@@ -121,6 +132,86 @@ 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
|
||||
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
|
||||
return workflowStatus.value.get(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.
|
||||
@@ -273,6 +364,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>) {
|
||||
@@ -288,6 +383,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>) {
|
||||
@@ -301,6 +397,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)
|
||||
}
|
||||
@@ -312,6 +412,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
|
||||
const jobId = e.detail.prompt_id
|
||||
setWorkflowStatus(jobId, 'completed')
|
||||
const queuedJob = queuedJobs.value[jobId]
|
||||
const telemetry = useTelemetry()
|
||||
if (queuedJob) {
|
||||
@@ -433,7 +534,11 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
if (isCloud) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Account preconditions (sign-in, subscription, credits) open their own
|
||||
@@ -441,10 +546,12 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
if (handleAccountPreconditionError(e.detail)) 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)
|
||||
@@ -569,6 +676,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]
|
||||
@@ -624,6 +732,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
|
||||
queuedJob.workflow = workflow
|
||||
if (workflow) jobIdToWorkflow.set(String(id), workflow)
|
||||
queuedJob.shareId = workflow?.shareId
|
||||
const queuedMode = getWorkflowMode(workflow)
|
||||
queuedJob.viewMode = queuedMode
|
||||
@@ -635,6 +744,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)
|
||||
@@ -729,6 +851,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
nodeLocatorIdToExecutionId,
|
||||
jobIdToWorkflowId,
|
||||
jobIdToSessionWorkflowPath,
|
||||
ensureSessionWorkflowPath
|
||||
ensureSessionWorkflowPath,
|
||||
getWorkflowStatus,
|
||||
clearWorkflowStatus
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user