mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 13:48:49 +00:00
Compare commits
10 Commits
codex/cove
...
matt/be-11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70c193a963 | ||
|
|
825b83acf8 | ||
|
|
1686b34add | ||
|
|
3a6b0ee1b3 | ||
|
|
4a62a00e7b | ||
|
|
ffe0fcecad | ||
|
|
2ce5b0172a | ||
|
|
a6109c5c5e | ||
|
|
4d5b5f730c | ||
|
|
f1d76adc33 |
@@ -1,132 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { createApp, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useJobActions } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const { cancelJob, removeFailedJob, wrapWithErrorHandlingAsync } = vi.hoisted(
|
||||
() => ({
|
||||
cancelJob: vi.fn(),
|
||||
removeFailedJob: vi.fn(),
|
||||
wrapWithErrorHandlingAsync: vi.fn(
|
||||
<T extends (...args: never[]) => Promise<unknown>>(fn: T) => fn
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({ wrapWithErrorHandlingAsync })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/queue/useJobMenu', () => ({
|
||||
useJobMenu: () => ({ cancelJob, removeFailedJob })
|
||||
}))
|
||||
|
||||
function mountJobActions(job: Ref<JobListItem | null | undefined>) {
|
||||
let result: ReturnType<typeof useJobActions> | undefined
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = useJobActions(job)
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
app.use(
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('useJobActions did not initialize')
|
||||
return {
|
||||
result,
|
||||
unmount: () => app.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
function job(overrides: Partial<JobListItem> = {}): JobListItem {
|
||||
return {
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: '',
|
||||
state: 'pending',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cancelJob.mockReset().mockResolvedValue(undefined)
|
||||
removeFailedJob.mockReset().mockResolvedValue(undefined)
|
||||
wrapWithErrorHandlingAsync.mockClear()
|
||||
})
|
||||
|
||||
describe('useJobActions', () => {
|
||||
it('exposes localized action metadata', () => {
|
||||
const { result, unmount } = mountJobActions(ref(job()))
|
||||
|
||||
expect(result.cancelAction).toMatchObject({
|
||||
icon: 'icon-[lucide--x]',
|
||||
label: 'sideToolbar.queueProgressOverlay.cancelJobTooltip',
|
||||
variant: 'destructive'
|
||||
})
|
||||
expect(result.deleteAction).toMatchObject({
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
label: 'queue.jobMenu.removeJob',
|
||||
variant: 'destructive'
|
||||
})
|
||||
expect(wrapWithErrorHandlingAsync).toHaveBeenCalledTimes(2)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('cancels active jobs unless clearing is hidden', async () => {
|
||||
const currentJob = ref(job({ state: 'running' }))
|
||||
const { result, unmount } = mountJobActions(currentJob)
|
||||
|
||||
expect(result.canCancelJob.value).toBe(true)
|
||||
await result.runCancelJob()
|
||||
expect(cancelJob).toHaveBeenCalledWith(currentJob.value)
|
||||
|
||||
currentJob.value = job({ state: 'pending', showClear: false })
|
||||
expect(result.canCancelJob.value).toBe(false)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('ignores cancel and delete requests without a current job or task', async () => {
|
||||
const currentJob = ref<JobListItem | null>(null)
|
||||
const { result, unmount } = mountJobActions(currentJob)
|
||||
|
||||
expect(result.canCancelJob.value).toBe(false)
|
||||
expect(result.canDeleteJob.value).toBe(false)
|
||||
await result.runCancelJob()
|
||||
await result.runDeleteJob()
|
||||
|
||||
currentJob.value = job({ state: 'failed' })
|
||||
await result.runDeleteJob()
|
||||
|
||||
expect(cancelJob).not.toHaveBeenCalled()
|
||||
expect(removeFailedJob).not.toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('removes failed jobs through their queue task', async () => {
|
||||
const task = fromPartial<TaskItemImpl>({ job: { id: 'prompt-1' } })
|
||||
const { result, unmount } = mountJobActions(
|
||||
ref(job({ state: 'failed', taskRef: task }))
|
||||
)
|
||||
|
||||
expect(result.canDeleteJob.value).toBe(true)
|
||||
await result.runDeleteJob()
|
||||
|
||||
expect(removeFailedJob).toHaveBeenCalledWith(task)
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -280,20 +280,6 @@ describe('useJobMenu', () => {
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses an empty menu with the default current item getter', async () => {
|
||||
const { jobMenuEntries, openJobWorkflow, copyJobId, cancelJob } =
|
||||
useJobMenu()
|
||||
|
||||
await openJobWorkflow()
|
||||
await copyJobId()
|
||||
await cancelJob()
|
||||
|
||||
expect(jobMenuEntries.value).toEqual([])
|
||||
expect(getJobWorkflowMock).not.toHaveBeenCalled()
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.for([['running'], ['initialization'], ['pending']])(
|
||||
'cancels %s job via the state-agnostic jobs-namespace endpoint',
|
||||
async ([state]) => {
|
||||
@@ -407,26 +393,6 @@ describe('useJobMenu', () => {
|
||||
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
|
||||
})
|
||||
|
||||
it('ignores failed report action when item disappears before click', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: {
|
||||
errorMessage: 'Job failed with error'
|
||||
} as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
setCurrentItem(null)
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores error actions when message missing', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
@@ -578,74 +544,6 @@ describe('useJobMenu', () => {
|
||||
expect(createAnnotatedPathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses no root folder when preview type is not an API result type', async () => {
|
||||
const node = {
|
||||
widgets: [{ name: 'image', value: null, callback: vi.fn() }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
filename: 'foo.png',
|
||||
subfolder: 'bar',
|
||||
type: 'archive',
|
||||
url: 'http://asset'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(createAnnotatedPathMock).toHaveBeenCalledWith(
|
||||
{
|
||||
filename: 'foo.png',
|
||||
subfolder: 'bar',
|
||||
type: undefined
|
||||
},
|
||||
{ rootFolder: undefined },
|
||||
undefined
|
||||
)
|
||||
expect(node.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('marks graph dirty when created loader node has no matching widget', async () => {
|
||||
const node = {
|
||||
widgets: [{ name: 'other', value: null, callback: vi.fn() }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
filename: 'foo.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(node.widgets[0].value).toBeNull()
|
||||
expect(node.widgets[0].callback).not.toHaveBeenCalled()
|
||||
expect(node.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('ignores add-to-current entry when preview missing entirely', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
@@ -696,45 +594,6 @@ describe('useJobMenu', () => {
|
||||
expect(downloadFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores completed asset actions when item disappears before click', async () => {
|
||||
const inspectSpy = vi.fn()
|
||||
const { jobMenuEntries } = mountJobMenu(inspectSpy)
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
filename: 'foo.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
url: 'https://asset'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
|
||||
const addEntry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
const downloadEntry = findActionEntry(jobMenuEntries.value, 'download')
|
||||
const exportEntry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
const deleteEntry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
setCurrentItem(null)
|
||||
|
||||
void inspectEntry?.onClick?.()
|
||||
await addEntry?.onClick?.()
|
||||
void downloadEntry?.onClick?.()
|
||||
await exportEntry?.onClick?.()
|
||||
await deleteEntry?.onClick?.()
|
||||
|
||||
expect(inspectSpy).not.toHaveBeenCalled()
|
||||
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(downloadFileMock).not.toHaveBeenCalled()
|
||||
expect(getJobWorkflowMock).not.toHaveBeenCalled()
|
||||
expect(mediaAssetActionsMock.deleteAssets).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exports workflow with default filename when prompting disabled', async () => {
|
||||
const workflow = { foo: 'bar' }
|
||||
getJobWorkflowMock.mockResolvedValue(workflow)
|
||||
@@ -759,17 +618,6 @@ describe('useJobMenu', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not export workflow when workflow data is unavailable', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'completed' }))
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(downloadBlobMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prompts for filename when setting enabled', async () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('custom-name')
|
||||
@@ -865,24 +713,6 @@ describe('useJobMenu', () => {
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not delete asset when preview disappears before click', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(mediaAssetActionsMock.deleteAssets).not.toHaveBeenCalled()
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes failed job via menu entry', async () => {
|
||||
const taskRef = { id: 'task-1' }
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
|
||||
@@ -79,10 +79,12 @@ describe(useQueueNotificationBanners, () => {
|
||||
isImage?: boolean
|
||||
} = {}
|
||||
): MockTask => {
|
||||
const { state = 'Completed', previewUrl, isImage = true } = options
|
||||
// Only default the timestamp when the caller omitted the key, so an
|
||||
// explicit `ts: undefined` really produces a task without a timestamp.
|
||||
const ts = 'ts' in options ? options.ts : Date.now()
|
||||
const {
|
||||
state = 'Completed',
|
||||
ts = Date.now(),
|
||||
previewUrl,
|
||||
isImage = true
|
||||
} = options
|
||||
|
||||
const task: MockTask = {
|
||||
displayStatus: state,
|
||||
@@ -184,75 +186,6 @@ describe(useQueueNotificationBanners, () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('converts a queued-pending notification waiting behind the active one', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueued', {
|
||||
detail: { requestId: 1, batchCount: 1 }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueueing', {
|
||||
detail: { requestId: 2, batchCount: 3 }
|
||||
})
|
||||
)
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueued', {
|
||||
detail: { requestId: 2, batchCount: 5 }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'queued',
|
||||
count: 1,
|
||||
requestId: 1
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'queued',
|
||||
count: 5,
|
||||
requestId: 2
|
||||
})
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('converts queued-pending notifications without request ids', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueueing', {
|
||||
detail: { batchCount: 2 }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueued', {
|
||||
detail: { batchCount: 3 }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'queued',
|
||||
count: 3
|
||||
})
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to 1 when queued batch count is invalid', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
@@ -373,64 +306,6 @@ describe(useQueueNotificationBanners, () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('shows failed notifications for failed-only batches', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
await runBatch({
|
||||
start: 5_000,
|
||||
finish: 5_200,
|
||||
tasks: [createTask({ state: 'Failed', ts: 5_050 })]
|
||||
})
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'failed',
|
||||
count: 1
|
||||
})
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not notify for old or unfinished history entries', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
await runBatch({
|
||||
start: 6_000,
|
||||
finish: 6_200,
|
||||
tasks: [
|
||||
createTask({ ts: 5_999 }),
|
||||
createTask({ state: 'Running', ts: 6_050 }),
|
||||
createTask({ state: 'Pending', ts: undefined })
|
||||
]
|
||||
})
|
||||
|
||||
expect(composable.currentNotification.value).toBeNull()
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps no notification visible when an idle window has no finished tasks', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
vi.setSystemTime(7_000)
|
||||
executionStore().isIdle = false
|
||||
await nextTick()
|
||||
|
||||
vi.setSystemTime(7_100)
|
||||
executionStore().isIdle = true
|
||||
queueStore().historyTasks = []
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toBeNull()
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses up to two completion thumbnails for notification icon previews', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
|
||||
@@ -63,7 +63,8 @@ function buildResponse(
|
||||
return {
|
||||
ok: init.ok ?? true,
|
||||
status: init.status ?? 200,
|
||||
json: vi.fn().mockResolvedValue(body)
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
text: vi.fn().mockResolvedValue(JSON.stringify(body))
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { parseErrorResponse } from '@/platform/remote/comfyui/errors'
|
||||
|
||||
export interface PaginationOptions {
|
||||
limit?: number
|
||||
@@ -692,10 +693,8 @@ function createAssetService() {
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}))
|
||||
throw new Error(
|
||||
getLocalizedErrorMessage(errorData.code || 'UNKNOWN_ERROR')
|
||||
)
|
||||
const { code } = await parseErrorResponse(res)
|
||||
throw new Error(getLocalizedErrorMessage(code))
|
||||
}
|
||||
|
||||
const data: AssetMetadata = await res.json()
|
||||
|
||||
@@ -21,6 +21,7 @@ import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { parseErrorResponse } from '@/platform/remote/comfyui/errors'
|
||||
import {
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_EVENT,
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
|
||||
@@ -329,10 +330,10 @@ function useSubscriptionInternal() {
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
const { message } = await parseErrorResponse(response)
|
||||
throw new AuthStoreError(
|
||||
t('toastMessages.failedToFetchSubscription', {
|
||||
error: errorData.message
|
||||
error: message
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -422,10 +423,10 @@ function useSubscriptionInternal() {
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
const { message } = await parseErrorResponse(response)
|
||||
throw new AuthStoreError(
|
||||
t('toastMessages.failedToInitiateSubscription', {
|
||||
error: errorData.message
|
||||
error: message
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ import type {
|
||||
CheckoutAttributionMetadata,
|
||||
PaymentIntentSource
|
||||
} from '@/platform/telemetry/types'
|
||||
import { parseErrorResponse } from '@/platform/remote/comfyui/errors'
|
||||
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
||||
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
type CheckoutTier = TierKey | `${TierKey}-yearly`
|
||||
@@ -97,24 +99,11 @@ export async function performSubscriptionCheckout(
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to initiate checkout'
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
errorMessage = errorData.message || errorMessage
|
||||
} catch {
|
||||
// If JSON parsing fails, try to get text response or use HTTP status
|
||||
try {
|
||||
const errorText = await response.text()
|
||||
errorMessage =
|
||||
errorText || `HTTP ${response.status} ${response.statusText}`
|
||||
} catch {
|
||||
errorMessage = `HTTP ${response.status} ${response.statusText}`
|
||||
}
|
||||
}
|
||||
const { message } = await parseErrorResponse(response)
|
||||
|
||||
throw new AuthStoreError(
|
||||
t('toastMessages.failedToInitiateSubscription', {
|
||||
error: errorMessage
|
||||
error: message
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
185
src/platform/remote/comfyui/errors.test.ts
Normal file
185
src/platform/remote/comfyui/errors.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { errorResponseFromBody, parseErrorResponse } from './errors'
|
||||
|
||||
describe('errorResponseFromBody', () => {
|
||||
it('passes through a canonical body with details', () => {
|
||||
const body = {
|
||||
code: 'FILE_TOO_LARGE',
|
||||
message: 'File too large',
|
||||
details: { max_bytes: 1024 }
|
||||
}
|
||||
expect(errorResponseFromBody(body, 'fallback')).toEqual(body)
|
||||
})
|
||||
|
||||
it('passes through a canonical body without details', () => {
|
||||
const result = errorResponseFromBody(
|
||||
{ code: 'NOT_FOUND', message: 'Asset not found' },
|
||||
'fallback'
|
||||
)
|
||||
expect(result).toEqual({ code: 'NOT_FOUND', message: 'Asset not found' })
|
||||
expect('details' in result).toBe(false)
|
||||
})
|
||||
|
||||
it('salvages a legacy message-only body', () => {
|
||||
expect(errorResponseFromBody({ message: 'Forbidden' }, 'fallback')).toEqual(
|
||||
{
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'Forbidden'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('salvages a code-only body using the fallback message', () => {
|
||||
expect(errorResponseFromBody({ code: 'RATE_LIMITED' }, 'fallback')).toEqual(
|
||||
{
|
||||
code: 'RATE_LIMITED',
|
||||
message: 'fallback'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back entirely for non-object, non-string bodies', () => {
|
||||
for (const body of [undefined, null, 42, true, ['x']]) {
|
||||
expect(errorResponseFromBody(body, 'fallback')).toEqual({
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'fallback'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('uses a plain-string body as the message', () => {
|
||||
expect(errorResponseFromBody('Service Unavailable', 'fallback')).toEqual({
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'Service Unavailable'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back for a blank-string body', () => {
|
||||
expect(errorResponseFromBody(' ', 'fallback')).toEqual({
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'fallback'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats empty-string code and message as missing', () => {
|
||||
expect(
|
||||
errorResponseFromBody({ code: '', message: '' }, 'fallback')
|
||||
).toEqual({
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'fallback'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores non-string code and message values', () => {
|
||||
expect(
|
||||
errorResponseFromBody({ code: 42, message: {} }, 'fallback')
|
||||
).toEqual({
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'fallback'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops non-object details', () => {
|
||||
const result = errorResponseFromBody(
|
||||
{ code: 'X', message: 'y', details: 'not an object' },
|
||||
'fallback'
|
||||
)
|
||||
expect('details' in result).toBe(false)
|
||||
const arrayDetails = errorResponseFromBody(
|
||||
{ code: 'X', message: 'y', details: [1, 2] },
|
||||
'fallback'
|
||||
)
|
||||
expect('details' in arrayDetails).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseErrorResponse', () => {
|
||||
function makeResponse(overrides: {
|
||||
text?: () => Promise<string>
|
||||
status?: number
|
||||
statusText?: string
|
||||
}): Response {
|
||||
return {
|
||||
status: overrides.status ?? 500,
|
||||
statusText: overrides.statusText ?? 'Internal Server Error',
|
||||
text: overrides.text ?? (async () => '{}')
|
||||
} as Response
|
||||
}
|
||||
|
||||
it('parses a canonical error body', async () => {
|
||||
const response = makeResponse({
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
code: 'INVALID_INPUT',
|
||||
message: 'Bad field',
|
||||
details: { field: 'name' }
|
||||
})
|
||||
})
|
||||
await expect(parseErrorResponse(response)).resolves.toEqual({
|
||||
code: 'INVALID_INPUT',
|
||||
message: 'Bad field',
|
||||
details: { field: 'name' }
|
||||
})
|
||||
})
|
||||
|
||||
it('salvages a legacy message-only body', async () => {
|
||||
const response = makeResponse({
|
||||
text: async () => JSON.stringify({ message: 'Nope' })
|
||||
})
|
||||
await expect(parseErrorResponse(response)).resolves.toEqual({
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'Nope'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a plain-text body as the message', async () => {
|
||||
const response = makeResponse({
|
||||
text: async () => 'upstream connect error',
|
||||
statusText: 'Bad Gateway',
|
||||
status: 502
|
||||
})
|
||||
await expect(parseErrorResponse(response)).resolves.toEqual({
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'upstream connect error'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to statusText when the body is empty', async () => {
|
||||
const response = makeResponse({
|
||||
text: async () => '',
|
||||
statusText: 'Bad Gateway',
|
||||
status: 502
|
||||
})
|
||||
await expect(parseErrorResponse(response)).resolves.toEqual({
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'Bad Gateway'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to statusText when the body cannot be read', async () => {
|
||||
const response = makeResponse({
|
||||
text: async () => {
|
||||
throw new TypeError('stream failed')
|
||||
},
|
||||
statusText: 'Bad Gateway',
|
||||
status: 502
|
||||
})
|
||||
await expect(parseErrorResponse(response)).resolves.toEqual({
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'Bad Gateway'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the status code when statusText is empty', async () => {
|
||||
const response = makeResponse({
|
||||
text: async () => '',
|
||||
statusText: '',
|
||||
status: 402
|
||||
})
|
||||
await expect(parseErrorResponse(response)).resolves.toEqual({
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'HTTP 402'
|
||||
})
|
||||
})
|
||||
})
|
||||
73
src/platform/remote/comfyui/errors.ts
Normal file
73
src/platform/remote/comfyui/errors.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { ErrorResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
/** Code reported when an error payload carries no machine-readable code. */
|
||||
const UNKNOWN_ERROR_CODE = 'UNKNOWN_ERROR'
|
||||
|
||||
/**
|
||||
* Coerce an already-parsed error body into the canonical
|
||||
* `ErrorResponse { code, message, details? }` shape.
|
||||
*
|
||||
* The API emits this shape for all error responses; this helper is the
|
||||
* single place that tolerates legacy/partial payloads (missing `code`,
|
||||
* missing `message`, non-object bodies) so call sites never shape-sniff.
|
||||
*
|
||||
* @param body - The parsed response body (any JSON value, or `undefined`)
|
||||
* @param fallbackMessage - Used when the body carries no usable message
|
||||
*/
|
||||
export function errorResponseFromBody(
|
||||
body: unknown,
|
||||
fallbackMessage: string
|
||||
): ErrorResponse {
|
||||
if (typeof body === 'string') {
|
||||
return {
|
||||
code: UNKNOWN_ERROR_CODE,
|
||||
message: body.trim() !== '' ? body : fallbackMessage
|
||||
}
|
||||
}
|
||||
const record =
|
||||
typeof body === 'object' && body !== null && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>)
|
||||
: {}
|
||||
const code =
|
||||
typeof record.code === 'string' && record.code !== ''
|
||||
? record.code
|
||||
: UNKNOWN_ERROR_CODE
|
||||
const message =
|
||||
typeof record.message === 'string' && record.message !== ''
|
||||
? record.message
|
||||
: fallbackMessage
|
||||
const details =
|
||||
typeof record.details === 'object' &&
|
||||
record.details !== null &&
|
||||
!Array.isArray(record.details)
|
||||
? (record.details as Record<string, unknown>)
|
||||
: undefined
|
||||
return details !== undefined ? { code, message, details } : { code, message }
|
||||
}
|
||||
|
||||
/** Parse JSON when possible, otherwise surface the raw text. */
|
||||
function parseJsonOrText(text: string): unknown {
|
||||
if (text.trim() === '') return undefined
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a failed HTTP `Response` into the canonical
|
||||
* `ErrorResponse { code, message, details? }` shape.
|
||||
*
|
||||
* Never throws: the body is read as text and JSON-parsed when possible, so
|
||||
* plain-text error bodies (e.g. from a proxy) survive as the message. Empty
|
||||
* or unreadable bodies degrade to a status-derived message and the
|
||||
* `UNKNOWN_ERROR` code.
|
||||
*/
|
||||
export async function parseErrorResponse(
|
||||
response: Response
|
||||
): Promise<ErrorResponse> {
|
||||
const fallbackMessage = response.statusText || `HTTP ${response.status}`
|
||||
const text = await response.text().catch(() => '')
|
||||
return errorResponseFromBody(parseJsonOrText(text), fallbackMessage)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from '@/scripts/api'
|
||||
import { parseErrorResponse } from '@/platform/remote/comfyui/errors'
|
||||
|
||||
import type {
|
||||
SecretCreateRequest,
|
||||
@@ -12,11 +13,6 @@ interface ListSecretsResponse {
|
||||
data: SecretMetadata[]
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
message?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export class SecretsApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -30,22 +26,13 @@ export class SecretsApiError extends Error {
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let errorData: ErrorResponse = {}
|
||||
try {
|
||||
errorData = await response.json()
|
||||
} catch {
|
||||
// Response body is not JSON
|
||||
}
|
||||
const errorData = await parseErrorResponse(response)
|
||||
const code = SECRET_ERROR_CODES.includes(
|
||||
errorData.code as (typeof SECRET_ERROR_CODES)[number]
|
||||
)
|
||||
? (errorData.code as SecretErrorCode)
|
||||
: undefined
|
||||
throw new SecretsApiError(
|
||||
errorData.message ?? response.statusText,
|
||||
response.status,
|
||||
code
|
||||
)
|
||||
throw new SecretsApiError(errorData.message, response.status, code)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { UserId } from '@/types/authTypes'
|
||||
import { errorResponseFromBody } from '@/platform/remote/comfyui/errors'
|
||||
|
||||
export type WorkspaceType = 'personal' | 'team'
|
||||
export type WorkspaceRole = 'owner' | 'member'
|
||||
@@ -351,7 +352,7 @@ async function getAuthHeaderOrThrow() {
|
||||
function handleAxiosError(err: unknown): never {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status
|
||||
const message = err.response?.data?.message ?? err.message
|
||||
const { message } = errorResponseFromBody(err.response?.data, err.message)
|
||||
throw new WorkspaceApiError(message, status)
|
||||
}
|
||||
throw err
|
||||
|
||||
@@ -341,7 +341,8 @@ describe('useWorkspaceAuthStore', () => {
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
json: () => Promise.resolve({ message: 'Access denied' })
|
||||
text: () =>
|
||||
Promise.resolve(JSON.stringify({ message: 'Access denied' }))
|
||||
})
|
||||
)
|
||||
|
||||
@@ -364,7 +365,8 @@ describe('useWorkspaceAuthStore', () => {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: () => Promise.resolve({ message: 'Workspace not found' })
|
||||
text: () =>
|
||||
Promise.resolve(JSON.stringify({ message: 'Workspace not found' }))
|
||||
})
|
||||
)
|
||||
|
||||
@@ -389,7 +391,8 @@ describe('useWorkspaceAuthStore', () => {
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
json: () => Promise.resolve({ message: 'Invalid token' })
|
||||
text: () =>
|
||||
Promise.resolve(JSON.stringify({ message: 'Invalid token' }))
|
||||
})
|
||||
)
|
||||
|
||||
@@ -414,7 +417,8 @@ describe('useWorkspaceAuthStore', () => {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.resolve({ message: 'Server error' })
|
||||
text: () =>
|
||||
Promise.resolve(JSON.stringify({ message: 'Server error' }))
|
||||
})
|
||||
)
|
||||
|
||||
@@ -631,7 +635,8 @@ describe('useWorkspaceAuthStore', () => {
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
json: () => Promise.resolve({ message: 'Access denied' })
|
||||
text: () =>
|
||||
Promise.resolve(JSON.stringify({ message: 'Access denied' }))
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
@@ -805,7 +810,7 @@ describe('useWorkspaceAuthStore', () => {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.resolve({ message: 'Server error' })
|
||||
text: () => Promise.resolve(JSON.stringify({ message: 'Server error' }))
|
||||
})
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
@@ -897,7 +902,8 @@ describe('useWorkspaceAuthStore', () => {
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
json: () => Promise.resolve({ message: 'Invalid token' })
|
||||
text: () =>
|
||||
Promise.resolve(JSON.stringify({ message: 'Invalid token' }))
|
||||
})
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
@@ -939,7 +945,8 @@ describe('useWorkspaceAuthStore', () => {
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
json: () => Promise.resolve({ message: 'Access denied' })
|
||||
text: () =>
|
||||
Promise.resolve(JSON.stringify({ message: 'Access denied' }))
|
||||
})
|
||||
await expect(store.switchWorkspace('workspace-other')).rejects.toThrow(
|
||||
WorkspaceAuthError
|
||||
@@ -1211,7 +1218,7 @@ describe('useWorkspaceAuthStore', () => {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.resolve({ message: 'Server error' })
|
||||
text: () => Promise.resolve(JSON.stringify({ message: 'Server error' }))
|
||||
})
|
||||
await refreshPromise
|
||||
|
||||
@@ -1475,7 +1482,8 @@ describe('useWorkspaceAuthStore', () => {
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
json: () => Promise.resolve({ message: 'Invalid token' })
|
||||
text: () =>
|
||||
Promise.resolve(JSON.stringify({ message: 'Invalid token' }))
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
@@ -1514,7 +1522,7 @@ describe('useWorkspaceAuthStore', () => {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.resolve({ message: 'try again' })
|
||||
text: () => Promise.resolve(JSON.stringify({ message: 'try again' }))
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
@@ -1686,7 +1694,7 @@ describe('useWorkspaceAuthStore', () => {
|
||||
ok: false,
|
||||
status,
|
||||
statusText,
|
||||
json: () => Promise.resolve({ message: statusText })
|
||||
text: () => Promise.resolve(JSON.stringify({ message: statusText }))
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
@@ -1781,7 +1789,7 @@ describe('useWorkspaceAuthStore', () => {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.resolve({ message: 'try again' })
|
||||
text: () => Promise.resolve(JSON.stringify({ message: 'try again' }))
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
@@ -1804,7 +1812,8 @@ describe('useWorkspaceAuthStore', () => {
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
json: () => Promise.resolve({ message: 'Invalid token' })
|
||||
text: () =>
|
||||
Promise.resolve(JSON.stringify({ message: 'Invalid token' }))
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
import { parseErrorResponse } from '@/platform/remote/comfyui/errors'
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/workspaceTypes'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
|
||||
@@ -312,8 +313,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
const message = errorData.message || response.statusText
|
||||
const { message } = await parseErrorResponse(response)
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new WorkspaceAuthError(
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuth
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { parseErrorResponse } from '@/platform/remote/comfyui/errors'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
|
||||
type CreditPurchaseResponse =
|
||||
@@ -308,10 +309,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
// Customer not found is expected for new users
|
||||
return null
|
||||
}
|
||||
const errorData = await response.json()
|
||||
const { message } = await parseErrorResponse(response)
|
||||
throw new AuthStoreError(
|
||||
t('toastMessages.failedToFetchBalance', {
|
||||
error: errorData.message
|
||||
error: message
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -550,10 +551,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
const { message } = await parseErrorResponse(response)
|
||||
throw new AuthStoreError(
|
||||
t('toastMessages.failedToInitiateCreditPurchase', {
|
||||
error: errorData.message
|
||||
error: message
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -590,10 +591,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
const { message } = await parseErrorResponse(response)
|
||||
throw new AuthStoreError(
|
||||
t('toastMessages.failedToAccessBillingPortal', {
|
||||
error: errorData.message
|
||||
error: message
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
const { handlers, openSet } = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
|
||||
openSet: new Set<unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
|
||||
handlers[name] = fn
|
||||
},
|
||||
removeEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
isOpen: (workflow: unknown) => openSet.has(workflow),
|
||||
openWorkflows: [],
|
||||
nodeLocatorIdToNodeExecutionId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => ({
|
||||
clearExecutionStartErrors: () => {},
|
||||
clearPromptError: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
|
||||
|
||||
vi.mock('@/utils/appMode', () => ({
|
||||
getWorkflowMode: () => 'workflow',
|
||||
isAppModeValue: () => false
|
||||
}))
|
||||
|
||||
function workflow(path: string): ComfyWorkflow {
|
||||
return { path } as unknown as ComfyWorkflow
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
return store
|
||||
}
|
||||
|
||||
function promptOutput(): ComfyApiWorkflow {
|
||||
return {}
|
||||
}
|
||||
|
||||
function startJob(
|
||||
store: ReturnType<typeof useExecutionStore>,
|
||||
id: string,
|
||||
wf: ComfyWorkflow,
|
||||
nodes: string[] = []
|
||||
) {
|
||||
openSet.add(wf)
|
||||
store.storeJob({ nodes, id, promptOutput: promptOutput(), workflow: wf })
|
||||
handlers['execution_start']?.({ detail: { prompt_id: id } })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
for (const key of Object.keys(handlers)) delete handlers[key]
|
||||
openSet.clear()
|
||||
})
|
||||
|
||||
describe('executionStore interrupt and cached', () => {
|
||||
it('drops the workflow badge and goes idle on interruption', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('a.json')
|
||||
startJob(store, 'job-1', wf)
|
||||
expect(store.getWorkflowStatus(wf)).toBe('running')
|
||||
|
||||
handlers['execution_interrupted']?.({ detail: { prompt_id: 'job-1' } })
|
||||
|
||||
expect(store.getWorkflowStatus(wf)).toBeUndefined()
|
||||
expect(store.isIdle).toBe(true)
|
||||
})
|
||||
|
||||
it('ends the active job when executing resolves to null', () => {
|
||||
const store = setup()
|
||||
startJob(store, 'job-2', workflow('b.json'))
|
||||
expect(store.isIdle).toBe(false)
|
||||
|
||||
handlers['executing']?.({ detail: null })
|
||||
|
||||
expect(store.isIdle).toBe(true)
|
||||
})
|
||||
|
||||
it('marks cached nodes as executed', () => {
|
||||
const store = setup()
|
||||
startJob(store, 'job-3', workflow('c.json'), ['a', 'b', 'c'])
|
||||
expect(store.nodesExecuted).toBe(0)
|
||||
|
||||
handlers['execution_cached']?.({
|
||||
detail: { prompt_id: 'job-3', nodes: ['a', 'b'] }
|
||||
})
|
||||
|
||||
expect(store.nodesExecuted).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -1,119 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
const { handlers } = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (e: { detail: unknown }) => void>
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
|
||||
handlers[name] = fn
|
||||
},
|
||||
removeEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
isOpen: () => false,
|
||||
openWorkflows: [],
|
||||
nodeLocatorIdToNodeExecutionId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => ({
|
||||
clearExecutionStartErrors: () => {},
|
||||
clearPromptError: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
|
||||
|
||||
vi.mock('@/utils/appMode', () => ({
|
||||
getWorkflowMode: () => 'workflow',
|
||||
isAppModeValue: () => false
|
||||
}))
|
||||
|
||||
function setup() {
|
||||
const store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
return store
|
||||
}
|
||||
|
||||
function promptOutput(): ComfyApiWorkflow {
|
||||
return {}
|
||||
}
|
||||
|
||||
function startJob(
|
||||
store: ReturnType<typeof useExecutionStore>,
|
||||
id: string,
|
||||
nodes: string[]
|
||||
) {
|
||||
store.storeJob({
|
||||
nodes,
|
||||
id,
|
||||
promptOutput: promptOutput(),
|
||||
workflow: { path: `${id}.json` } as unknown as ComfyWorkflow
|
||||
})
|
||||
handlers['execution_start']?.({ detail: { prompt_id: id } })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
for (const key of Object.keys(handlers)) delete handlers[key]
|
||||
})
|
||||
|
||||
describe('executionStore execution lifecycle', () => {
|
||||
it('reports zero progress while idle', () => {
|
||||
const store = setup()
|
||||
expect(store.totalNodesToExecute).toBe(0)
|
||||
expect(store.nodesExecuted).toBe(0)
|
||||
expect(store.executionProgress).toBe(0)
|
||||
})
|
||||
|
||||
it('counts the queued nodes once a job starts', () => {
|
||||
const store = setup()
|
||||
startJob(store, 'job-1', ['a', 'b', 'c'])
|
||||
|
||||
expect(store.totalNodesToExecute).toBe(3)
|
||||
expect(store.nodesExecuted).toBe(0)
|
||||
expect(store.executionProgress).toBe(0)
|
||||
})
|
||||
|
||||
it('advances progress as executed events arrive', () => {
|
||||
const store = setup()
|
||||
startJob(store, 'job-1', ['a', 'b', 'c'])
|
||||
|
||||
handlers['executed']?.({ detail: { node: 'a' } })
|
||||
expect(store.nodesExecuted).toBe(1)
|
||||
expect(store.executionProgress).toBeCloseTo(1 / 3)
|
||||
|
||||
handlers['executed']?.({ detail: { node: 'b' } })
|
||||
handlers['executed']?.({ detail: { node: 'c' } })
|
||||
expect(store.nodesExecuted).toBe(3)
|
||||
expect(store.executionProgress).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores executed events when there is no active job', () => {
|
||||
const store = setup()
|
||||
handlers['executed']?.({ detail: { node: 'a' } })
|
||||
expect(store.nodesExecuted).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,129 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
const { handlers } = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (e: { detail: unknown }) => void>
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
|
||||
handlers[name] = fn
|
||||
},
|
||||
removeEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
isOpen: () => false,
|
||||
openWorkflows: [],
|
||||
nodeLocatorIdToNodeExecutionId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => ({
|
||||
clearExecutionStartErrors: () => {},
|
||||
clearPromptError: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({ revokePreviewsByExecutionId: () => {} })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
|
||||
|
||||
vi.mock('@/utils/appMode', () => ({
|
||||
getWorkflowMode: () => 'workflow',
|
||||
isAppModeValue: () => false
|
||||
}))
|
||||
|
||||
// Derive from the real schema type so the test shape can't drift; keep the
|
||||
// non-essential fields optional so cases only spell out what they assert on.
|
||||
type NodeState = Partial<NodeProgressState> & Pick<NodeProgressState, 'state'>
|
||||
|
||||
function progressState(jobId: string, nodes: Record<string, NodeState>) {
|
||||
handlers['progress_state']?.({ detail: { prompt_id: jobId, nodes } })
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
return store
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
for (const key of Object.keys(handlers)) delete handlers[key]
|
||||
})
|
||||
|
||||
describe('executionStore node progress', () => {
|
||||
it('is idle until an execution starts', () => {
|
||||
const store = setup()
|
||||
expect(store.isIdle).toBe(true)
|
||||
|
||||
handlers['execution_start']?.({ detail: { prompt_id: 'job-1' } })
|
||||
expect(store.isIdle).toBe(false)
|
||||
})
|
||||
|
||||
it('derives the running node ids from a progress_state event', () => {
|
||||
const store = setup()
|
||||
|
||||
progressState('job-1', {
|
||||
n1: { state: 'running', value: 1, max: 4 },
|
||||
n2: { state: 'finished' },
|
||||
n3: { state: 'pending' }
|
||||
})
|
||||
|
||||
expect(store.executingNodeIds).toEqual(['n1'])
|
||||
expect(store.executingNodeId).toBe('n1')
|
||||
})
|
||||
|
||||
it('exposes fractional progress for the executing node', () => {
|
||||
const store = setup()
|
||||
|
||||
progressState('job-1', {
|
||||
n1: { state: 'running', value: 1, max: 4 }
|
||||
})
|
||||
|
||||
expect(store.executingNodeProgress).toBe(0.25)
|
||||
})
|
||||
|
||||
it('reports no executing node when none are running', () => {
|
||||
const store = setup()
|
||||
|
||||
progressState('job-1', {
|
||||
n1: { state: 'finished' },
|
||||
n2: { state: 'pending' }
|
||||
})
|
||||
|
||||
expect(store.executingNodeIds).toEqual([])
|
||||
expect(store.executingNodeId).toBeNull()
|
||||
})
|
||||
|
||||
it('replaces progress state on each progress_state event', () => {
|
||||
const store = setup()
|
||||
|
||||
progressState('job-1', { n1: { state: 'running', value: 1, max: 4 } })
|
||||
expect(store.executingNodeId).toBe('n1')
|
||||
|
||||
progressState('job-1', { n2: { state: 'running', value: 2, max: 2 } })
|
||||
expect(store.executingNodeIds).toEqual(['n2'])
|
||||
})
|
||||
})
|
||||
@@ -1,173 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import type { classifyCloudValidationError } from '@/utils/executionErrorUtil'
|
||||
|
||||
type CloudValidationResult = ReturnType<typeof classifyCloudValidationError>
|
||||
|
||||
const { handlers, errorStore, activeWorkflow, dist, classifyCloud } =
|
||||
vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
|
||||
errorStore: {
|
||||
clearExecutionStartErrors: () => {},
|
||||
clearPromptError: () => {}
|
||||
} as Record<string, unknown>,
|
||||
activeWorkflow: { value: null as { path: string } | null },
|
||||
dist: { isCloud: false },
|
||||
classifyCloud: vi.fn<(_: string) => CloudValidationResult>(() => null)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
|
||||
handlers[name] = fn
|
||||
},
|
||||
removeEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
isOpen: () => true,
|
||||
openWorkflows: [],
|
||||
nodeLocatorIdToNodeExecutionId: () => null,
|
||||
get activeWorkflow() {
|
||||
return activeWorkflow.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: undefined })
|
||||
}))
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => errorStore
|
||||
}))
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({ revokePreviewsByExecutionId: () => {} })
|
||||
}))
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
|
||||
}))
|
||||
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
|
||||
vi.mock('@/utils/appMode', () => ({
|
||||
getWorkflowMode: () => 'workflow',
|
||||
isAppModeValue: () => false
|
||||
}))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return dist.isCloud
|
||||
}
|
||||
}))
|
||||
vi.mock('@/platform/errorCatalog/accountPreconditionRouting', () => ({
|
||||
resolveAccountPrecondition: () => null
|
||||
}))
|
||||
vi.mock('@/utils/executionErrorUtil', () => ({
|
||||
classifyCloudValidationError: classifyCloud
|
||||
}))
|
||||
|
||||
function setup() {
|
||||
const store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
return store
|
||||
}
|
||||
|
||||
function workflow(path: string): ComfyWorkflow {
|
||||
return { path } as unknown as ComfyWorkflow
|
||||
}
|
||||
|
||||
function promptOutput(): ComfyApiWorkflow {
|
||||
return {}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
for (const key of Object.keys(handlers)) delete handlers[key]
|
||||
activeWorkflow.value = null
|
||||
dist.isCloud = false
|
||||
classifyCloud.mockReturnValue(null)
|
||||
for (const k of ['lastPromptError', 'lastNodeErrors', 'lastExecutionError'])
|
||||
delete errorStore[k]
|
||||
})
|
||||
|
||||
describe('executionStore running state and error edges', () => {
|
||||
it('lists jobs with a running node and counts running workflows', () => {
|
||||
const store = setup()
|
||||
handlers['progress_state']?.({
|
||||
detail: {
|
||||
prompt_id: 'job-1',
|
||||
nodes: { n1: { state: 'running', value: 1, max: 2 } }
|
||||
}
|
||||
})
|
||||
|
||||
expect(store.runningJobIds).toEqual(['job-1'])
|
||||
expect(store.runningWorkflowCount).toBe(1)
|
||||
})
|
||||
|
||||
it('does not report the active workflow as running when the path differs', () => {
|
||||
const store = setup()
|
||||
expect(store.isActiveWorkflowRunning).toBe(false)
|
||||
|
||||
const wf = workflow('w.json')
|
||||
activeWorkflow.value = { path: 'other.json' }
|
||||
store.storeJob({
|
||||
nodes: [],
|
||||
id: 'job-2',
|
||||
promptOutput: promptOutput(),
|
||||
workflow: wf
|
||||
})
|
||||
handlers['execution_start']?.({ detail: { prompt_id: 'job-2' } })
|
||||
|
||||
expect(store.isActiveWorkflowRunning).toBe(false)
|
||||
})
|
||||
|
||||
it('reports the active workflow as running when job, path and session agree', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('w.json')
|
||||
activeWorkflow.value = { path: 'w.json' }
|
||||
store.storeJob({
|
||||
nodes: [],
|
||||
id: 'job-2',
|
||||
promptOutput: promptOutput(),
|
||||
workflow: wf
|
||||
})
|
||||
handlers['execution_start']?.({ detail: { prompt_id: 'job-2' } })
|
||||
|
||||
expect(store.isActiveWorkflowRunning).toBe(true)
|
||||
})
|
||||
|
||||
it('formats a service-level error message from the exception message alone', () => {
|
||||
setup()
|
||||
handlers['execution_error']?.({
|
||||
detail: { prompt_id: 'job-3', exception_message: 'Job has stagnated' }
|
||||
})
|
||||
|
||||
expect(errorStore.lastPromptError).toEqual({
|
||||
type: 'error',
|
||||
message: 'Job has stagnated',
|
||||
details: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('stores a classified cloud prompt error on the prompt-error branch', () => {
|
||||
dist.isCloud = true
|
||||
classifyCloud.mockReturnValue({
|
||||
kind: 'promptError',
|
||||
promptError: { type: 'validation', message: 'bad input', details: '' }
|
||||
})
|
||||
setup()
|
||||
|
||||
handlers['execution_error']?.({
|
||||
detail: { prompt_id: 'job-4', exception_message: '{}' }
|
||||
})
|
||||
|
||||
expect(errorStore.lastPromptError).toEqual({
|
||||
type: 'validation',
|
||||
message: 'bad input',
|
||||
details: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -22,8 +22,7 @@ const {
|
||||
mockShowTextPreview,
|
||||
mockTrackExecutionError,
|
||||
mockTrackExecutionSuccess,
|
||||
mockTrackSharedWorkflowRun,
|
||||
mockRevokePreviewsByExecutionId
|
||||
mockTrackSharedWorkflowRun
|
||||
} = await vi.hoisted(async () => {
|
||||
const { shallowRef } = await import('vue')
|
||||
return {
|
||||
@@ -35,8 +34,7 @@ const {
|
||||
mockShowTextPreview: vi.fn(),
|
||||
mockTrackExecutionError: vi.fn(),
|
||||
mockTrackExecutionSuccess: vi.fn(),
|
||||
mockTrackSharedWorkflowRun: vi.fn(),
|
||||
mockRevokePreviewsByExecutionId: vi.fn()
|
||||
mockTrackSharedWorkflowRun: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -131,7 +129,7 @@ vi.mock('@/scripts/api', () => ({
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
revokePreviewsByExecutionId: mockRevokePreviewsByExecutionId
|
||||
revokePreviewsByExecutionId: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -425,124 +423,6 @@ describe('useExecutionStore - nodeLocationProgressStates caching', () => {
|
||||
'running'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an existing error state when later progress maps to the same locator', () => {
|
||||
store.nodeProgressStates = {
|
||||
node1: {
|
||||
display_node_id: '123',
|
||||
state: 'error',
|
||||
value: 0,
|
||||
max: 100,
|
||||
prompt_id: 'test',
|
||||
node_id: 'node1'
|
||||
},
|
||||
node2: {
|
||||
display_node_id: '123:456',
|
||||
state: 'running',
|
||||
value: 50,
|
||||
max: 100,
|
||||
prompt_id: 'test',
|
||||
node_id: 'node2'
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
|
||||
.state
|
||||
).toBe('error')
|
||||
})
|
||||
|
||||
it('ignores finished progress when current state is already running', () => {
|
||||
store.nodeProgressStates = {
|
||||
node1: {
|
||||
display_node_id: '123',
|
||||
state: 'running',
|
||||
value: 5,
|
||||
max: 10,
|
||||
prompt_id: 'test',
|
||||
node_id: 'node1'
|
||||
},
|
||||
node2: {
|
||||
display_node_id: '123',
|
||||
state: 'finished',
|
||||
value: 10,
|
||||
max: 10,
|
||||
prompt_id: 'test',
|
||||
node_id: 'node2'
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
|
||||
).toMatchObject({ state: 'running', value: 5 })
|
||||
})
|
||||
|
||||
it('keeps later running progress from moving a locator backwards', () => {
|
||||
store.nodeProgressStates = {
|
||||
node1: {
|
||||
display_node_id: '123',
|
||||
state: 'running',
|
||||
value: 6,
|
||||
max: 10,
|
||||
prompt_id: 'test',
|
||||
node_id: 'node1'
|
||||
},
|
||||
node2: {
|
||||
display_node_id: '123',
|
||||
state: 'running',
|
||||
value: 8,
|
||||
max: 10,
|
||||
prompt_id: 'test',
|
||||
node_id: 'node2'
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
|
||||
).toMatchObject({ state: 'running', value: 6, max: 10 })
|
||||
})
|
||||
|
||||
it('merges zero-max running progress without dividing by zero', () => {
|
||||
store.nodeProgressStates = {
|
||||
node1: {
|
||||
display_node_id: '123',
|
||||
state: 'pending',
|
||||
value: 0,
|
||||
max: 0,
|
||||
prompt_id: 'test',
|
||||
node_id: 'node1'
|
||||
},
|
||||
node2: {
|
||||
display_node_id: '123',
|
||||
state: 'running',
|
||||
value: 0,
|
||||
max: 0,
|
||||
prompt_id: 'test',
|
||||
node_id: 'node2'
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
|
||||
).toMatchObject({ state: 'running', value: 0, max: 0 })
|
||||
})
|
||||
|
||||
it('skips nested progress when the execution id cannot be resolved', () => {
|
||||
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(null)
|
||||
store.nodeProgressStates = {
|
||||
node1: {
|
||||
display_node_id: '404:1',
|
||||
state: 'running',
|
||||
value: 5,
|
||||
max: 10,
|
||||
prompt_id: 'test',
|
||||
node_id: 'node1'
|
||||
}
|
||||
}
|
||||
|
||||
expect(store.nodeLocationProgressStates).toHaveProperty('404')
|
||||
expect(store.nodeLocationProgressStates).not.toHaveProperty('404:1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
|
||||
@@ -671,33 +551,6 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
|
||||
|
||||
expect(store.initializingJobIds).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('clears initialization ids directly', () => {
|
||||
store.initializingJobIds = new Set(['job-1'])
|
||||
|
||||
store.clearInitializationByJobId(null)
|
||||
store.clearInitializationByJobId('missing')
|
||||
store.clearInitializationByJobId('job-1')
|
||||
|
||||
expect(store.initializingJobIds).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('checks initializing jobs by stringified id', () => {
|
||||
store.initializingJobIds = new Set(['7'])
|
||||
|
||||
expect(store.isJobInitializing(undefined)).toBe(false)
|
||||
expect(store.isJobInitializing(7)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not rewrite initializing state when no requested ids are tracked', () => {
|
||||
store.initializingJobIds = new Set(['job-1'])
|
||||
const before = store.initializingJobIds
|
||||
|
||||
store.clearInitializationByJobIds(['missing'])
|
||||
|
||||
expect(store.initializingJobIds).toBe(before)
|
||||
expect(store.initializingJobIds).toEqual(new Set(['job-1']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - workflowStatus', () => {
|
||||
@@ -822,16 +675,6 @@ describe('useExecutionStore - workflowStatus', () => {
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
|
||||
})
|
||||
|
||||
it('leaves workflowStatus unchanged when open workflows are unchanged', async () => {
|
||||
callStoreJob('job-a', workflowA)
|
||||
fireExecutionSuccess('job-a')
|
||||
|
||||
mockOpenWorkflows.value = [workflowA, workflowB]
|
||||
await nextTick()
|
||||
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
|
||||
})
|
||||
|
||||
it('sets failed on execution_error', () => {
|
||||
callStoreJob('job-1', workflowA)
|
||||
fireExecutionStart('job-1')
|
||||
@@ -848,14 +691,6 @@ describe('useExecutionStore - workflowStatus', () => {
|
||||
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles interrupt for a queued workflow with no active job', () => {
|
||||
callStoreJob('job-1', workflowA)
|
||||
|
||||
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.
|
||||
@@ -1065,35 +900,6 @@ describe('useExecutionStore - progress_text startup guard', () => {
|
||||
|
||||
expect(mockShowTextPreview).toHaveBeenCalledWith(mockNode, 'warming up')
|
||||
})
|
||||
|
||||
it('should ignore progress_text for another active prompt', async () => {
|
||||
const mockNode = createMockLGraphNode({ id: 1 })
|
||||
const { useCanvasStore } =
|
||||
await import('@/renderer/core/canvas/canvasStore')
|
||||
useCanvasStore().canvas = {
|
||||
graph: { getNodeById: vi.fn(() => mockNode) }
|
||||
} as unknown as LGraphCanvas
|
||||
store.activeJobId = 'job-1'
|
||||
|
||||
fireProgressText({
|
||||
nodeId: toNodeId('1'),
|
||||
text: 'warming up',
|
||||
prompt_id: 'job-2'
|
||||
})
|
||||
|
||||
expect(mockShowTextPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore progress_text without text or node id', () => {
|
||||
fireProgressText({ nodeId: toNodeId('1'), text: '' })
|
||||
fireProgressText({
|
||||
nodeId: '' as ReturnType<typeof toNodeId>,
|
||||
text: 'warming up'
|
||||
})
|
||||
|
||||
expect(mockShowTextPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore nested progress_text when the execution ID cannot be mapped', async () => {
|
||||
const { useCanvasStore } =
|
||||
await import('@/renderer/core/canvas/canvasStore')
|
||||
@@ -1109,19 +915,6 @@ describe('useExecutionStore - progress_text startup guard', () => {
|
||||
expect(mockExecutionIdToCurrentId).toHaveBeenCalledWith('1:2')
|
||||
expect(mockShowTextPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore progress_text when the current node id cannot be parsed', async () => {
|
||||
const { useCanvasStore } =
|
||||
await import('@/renderer/core/canvas/canvasStore')
|
||||
useCanvasStore().canvas = {
|
||||
graph: { getNodeById: vi.fn() }
|
||||
} as unknown as LGraphCanvas
|
||||
mockExecutionIdToCurrentId.mockReturnValue({})
|
||||
|
||||
fireProgressText({ nodeId: toNodeId('1:2'), text: 'warming up' })
|
||||
|
||||
expect(mockShowTextPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
@@ -1582,21 +1375,6 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
expect(store.initializingJobIds.has('job-1')).toBe(false)
|
||||
expect(store.initializingJobIds.has('job-2')).toBe(true)
|
||||
})
|
||||
|
||||
it('captures a queued workflow path when the start event wins the race', () => {
|
||||
store.queuedJobs = {
|
||||
'job-1': {
|
||||
nodes: {},
|
||||
workflow: createQueuedWorkflow('/workflows/race.json')
|
||||
}
|
||||
}
|
||||
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
|
||||
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe(
|
||||
'/workflows/race.json'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution_cached', () => {
|
||||
@@ -1784,35 +1562,9 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
is_app_mode: true
|
||||
})
|
||||
})
|
||||
|
||||
it('uses current mode when shared queued job has no queued mode snapshot', () => {
|
||||
mockAppModeState.mode.value = 'app'
|
||||
mockAppModeState.isAppMode.value = true
|
||||
store.queuedJobs = {
|
||||
'job-1': {
|
||||
nodes: {},
|
||||
shareId: 'share-1'
|
||||
}
|
||||
}
|
||||
|
||||
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
|
||||
|
||||
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
|
||||
job_id: 'job-1',
|
||||
share_id: 'share-1',
|
||||
view_mode: 'app',
|
||||
is_app_mode: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('executing', () => {
|
||||
it('is a no-op when there is no active job', () => {
|
||||
fire('executing', null)
|
||||
|
||||
expect(store.activeJobId).toBeNull()
|
||||
})
|
||||
|
||||
it('clears _executingNodeProgress and activeJobId when detail is null', () => {
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
store._executingNodeProgress = {
|
||||
@@ -1838,34 +1590,7 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('progress_state', () => {
|
||||
it('does not revoke previews when the node execution id is invalid', () => {
|
||||
mockRevokePreviewsByExecutionId.mockClear()
|
||||
|
||||
fire('progress_state', {
|
||||
prompt_id: 'job-1',
|
||||
nodes: {
|
||||
'': {
|
||||
value: 1,
|
||||
max: 2,
|
||||
state: 'running',
|
||||
node_id: '',
|
||||
display_node_id: '',
|
||||
prompt_id: 'job-1'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(mockRevokePreviewsByExecutionId).not.toHaveBeenCalled()
|
||||
expect(store.nodeProgressStates).toHaveProperty('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('progress', () => {
|
||||
it('reports null executing node progress before progress events arrive', () => {
|
||||
expect(store.executingNodeProgress).toBeNull()
|
||||
})
|
||||
|
||||
it('sets _executingNodeProgress from the event payload', () => {
|
||||
const payload = { value: 3, max: 10, prompt_id: 'job-1', node: 'n1' }
|
||||
|
||||
@@ -1885,24 +1610,6 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
expect(store.clientId).toBe('test-client')
|
||||
expect(removeSpy).toHaveBeenCalledWith('status', expect.any(Function))
|
||||
})
|
||||
|
||||
it('keeps listening when status arrives before clientId is available', async () => {
|
||||
const apiModule = await import('@/scripts/api')
|
||||
const removeSpy = vi.mocked(apiModule.api.removeEventListener)
|
||||
apiModule.api.clientId = ''
|
||||
|
||||
try {
|
||||
fire('status', { exec_info: { queue_remaining: 0 } })
|
||||
|
||||
expect(store.clientId).toBeNull()
|
||||
expect(removeSpy).not.toHaveBeenCalledWith(
|
||||
'status',
|
||||
expect.any(Function)
|
||||
)
|
||||
} finally {
|
||||
apiModule.api.clientId = 'test-client'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution_error', () => {
|
||||
@@ -1924,40 +1631,6 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the message directly for service-level errors without a type', () => {
|
||||
const errorStore = useExecutionErrorStore()
|
||||
|
||||
fire('execution_error', {
|
||||
prompt_id: 'job-1',
|
||||
node_id: null,
|
||||
exception_message: 'Job failed before node execution',
|
||||
traceback: []
|
||||
})
|
||||
|
||||
expect(errorStore.lastPromptError).toMatchObject({
|
||||
type: 'error',
|
||||
message: 'Job failed before node execution',
|
||||
details: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('uses an empty prompt message for service-level errors without backend copy', () => {
|
||||
const errorStore = useExecutionErrorStore()
|
||||
|
||||
fire('execution_error', {
|
||||
prompt_id: 'job-1',
|
||||
node_id: null,
|
||||
exception_message: '',
|
||||
traceback: []
|
||||
})
|
||||
|
||||
expect(errorStore.lastPromptError).toMatchObject({
|
||||
type: 'error',
|
||||
message: '',
|
||||
details: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('routes a runtime error (with node_id) to lastExecutionError', () => {
|
||||
const errorStore = useExecutionErrorStore()
|
||||
|
||||
@@ -2071,12 +1744,6 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
|
||||
expect(store.initializingJobIds.has('job-9')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores notifications without text', () => {
|
||||
fire('notification', { id: 'job-9' })
|
||||
|
||||
expect(store.initializingJobIds.has('job-9')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unbindExecutionEvents', () => {
|
||||
@@ -2146,45 +1813,6 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('storeJob works without workflow metadata', () => {
|
||||
const workflow = {} as Parameters<typeof store.storeJob>[0]['workflow']
|
||||
const missingWorkflow = undefined as unknown as Parameters<
|
||||
typeof store.storeJob
|
||||
>[0]['workflow']
|
||||
|
||||
store.storeJob({
|
||||
nodes: ['a'],
|
||||
id: 'job-1',
|
||||
promptOutput: {
|
||||
a: createPromptNode('Node A', 'NodeA')
|
||||
},
|
||||
workflow
|
||||
})
|
||||
|
||||
expect(store.queuedJobs['job-1']?.nodes).toEqual({ a: false })
|
||||
expect(store.jobIdToWorkflowId.has('job-1')).toBe(false)
|
||||
expect(store.jobIdToSessionWorkflowPath.has('job-1')).toBe(false)
|
||||
|
||||
store.storeJob({
|
||||
nodes: ['b'],
|
||||
id: 'job-2',
|
||||
promptOutput: {
|
||||
b: createPromptNode('Node B', 'NodeB')
|
||||
},
|
||||
workflow: missingWorkflow
|
||||
})
|
||||
|
||||
expect(store.queuedJobs['job-2']?.nodes).toEqual({ b: false })
|
||||
expect(store.queuedJobs['job-2']?.workflow).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports zero execution progress for an active job with no nodes', () => {
|
||||
store.activeJobId = 'job-1'
|
||||
store.queuedJobs = { 'job-1': { nodes: {} } }
|
||||
|
||||
expect(store.executionProgress).toBe(0)
|
||||
})
|
||||
|
||||
it('registerJobWorkflowIdMapping ignores empty inputs', () => {
|
||||
store.registerJobWorkflowIdMapping('job-1', 'wf-1')
|
||||
store.registerJobWorkflowIdMapping('', 'wf-2')
|
||||
@@ -2201,58 +1829,4 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => {
|
||||
|
||||
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe('/b.json')
|
||||
})
|
||||
|
||||
it('evicts the oldest workflow paths when the session map exceeds capacity', () => {
|
||||
for (let i = 0; i < 4001; i++) {
|
||||
store.ensureSessionWorkflowPath(`job-${i}`, `/workflow-${i}.json`)
|
||||
}
|
||||
|
||||
expect(store.jobIdToSessionWorkflowPath.size).toBe(4000)
|
||||
expect(store.jobIdToSessionWorkflowPath.has('job-0')).toBe(false)
|
||||
expect(store.jobIdToSessionWorkflowPath.get('job-4000')).toBe(
|
||||
'/workflow-4000.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports whether the active workflow is running', () => {
|
||||
mockActiveWorkflow.value = { path: '/workflows/foo.json' }
|
||||
store.activeJobId = 'job-1'
|
||||
store.ensureSessionWorkflowPath('job-1', '/workflows/foo.json')
|
||||
|
||||
expect(store.isActiveWorkflowRunning).toBe(true)
|
||||
|
||||
store.ensureSessionWorkflowPath('job-1', '/workflows/bar.json')
|
||||
expect(store.isActiveWorkflowRunning).toBe(false)
|
||||
|
||||
mockActiveWorkflow.value = {}
|
||||
expect(store.isActiveWorkflowRunning).toBe(false)
|
||||
})
|
||||
|
||||
it('counts running jobs from progress state', () => {
|
||||
store.nodeProgressStatesByJob = {
|
||||
'job-1': {
|
||||
a: {
|
||||
value: 1,
|
||||
max: 10,
|
||||
state: 'running',
|
||||
node_id: 'a',
|
||||
display_node_id: 'a',
|
||||
prompt_id: 'job-1'
|
||||
}
|
||||
},
|
||||
'job-2': {
|
||||
b: {
|
||||
value: 10,
|
||||
max: 10,
|
||||
state: 'finished',
|
||||
node_id: 'b',
|
||||
display_node_id: 'b',
|
||||
prompt_id: 'job-2'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(store.runningJobIds).toEqual(['job-1'])
|
||||
expect(store.runningWorkflowCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
const { handlers, openSet } = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
|
||||
openSet: new Set<unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
|
||||
handlers[name] = fn
|
||||
},
|
||||
removeEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
isOpen: (workflow: unknown) => openSet.has(workflow),
|
||||
openWorkflows: [],
|
||||
nodeLocatorIdToNodeExecutionId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => ({
|
||||
clearExecutionStartErrors: () => {},
|
||||
clearPromptError: () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
|
||||
|
||||
vi.mock('@/utils/appMode', () => ({
|
||||
getWorkflowMode: () => 'workflow',
|
||||
isAppModeValue: () => false
|
||||
}))
|
||||
|
||||
function workflow(path: string): ComfyWorkflow {
|
||||
return { path } as unknown as ComfyWorkflow
|
||||
}
|
||||
|
||||
function promptOutput(): ComfyApiWorkflow {
|
||||
return {}
|
||||
}
|
||||
|
||||
function storeJob(
|
||||
store: ReturnType<typeof useExecutionStore>,
|
||||
id: string,
|
||||
wf: ComfyWorkflow
|
||||
) {
|
||||
store.storeJob({ nodes: [], id, promptOutput: promptOutput(), workflow: wf })
|
||||
}
|
||||
|
||||
function fire(event: string, jobId: string) {
|
||||
handlers[event]?.({ detail: { prompt_id: jobId } })
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
return store
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
for (const key of Object.keys(handlers)) delete handlers[key]
|
||||
openSet.clear()
|
||||
})
|
||||
|
||||
describe('executionStore workflow status', () => {
|
||||
it('marks an open workflow running on execution_start and completed on success', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('a.json')
|
||||
openSet.add(wf)
|
||||
storeJob(store, 'job-1', wf)
|
||||
|
||||
fire('execution_start', 'job-1')
|
||||
expect(store.getWorkflowStatus(wf)).toBe('running')
|
||||
|
||||
fire('execution_success', 'job-1')
|
||||
expect(store.getWorkflowStatus(wf)).toBe('completed')
|
||||
})
|
||||
|
||||
it('buffers a status that arrives before the job is attached, then flushes on storeJob', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('b.json')
|
||||
openSet.add(wf)
|
||||
|
||||
fire('execution_start', 'job-2')
|
||||
expect(store.getWorkflowStatus(wf)).toBeUndefined()
|
||||
|
||||
storeJob(store, 'job-2', wf)
|
||||
expect(store.getWorkflowStatus(wf)).toBe('running')
|
||||
})
|
||||
|
||||
it('does not apply status to a workflow that is not open', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('c.json')
|
||||
storeJob(store, 'job-3', wf)
|
||||
|
||||
fire('execution_start', 'job-3')
|
||||
expect(store.getWorkflowStatus(wf)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears a workflow status', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('d.json')
|
||||
openSet.add(wf)
|
||||
storeJob(store, 'job-4', wf)
|
||||
fire('execution_start', 'job-4')
|
||||
expect(store.getWorkflowStatus(wf)).toBe('running')
|
||||
|
||||
store.clearWorkflowStatus(wf)
|
||||
expect(store.getWorkflowStatus(wf)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not let a late buffered running overwrite a terminal status', () => {
|
||||
const store = setup()
|
||||
const wf = workflow('e.json')
|
||||
openSet.add(wf)
|
||||
|
||||
storeJob(store, 'job-5', wf)
|
||||
fire('execution_success', 'job-5')
|
||||
expect(store.getWorkflowStatus(wf)).toBe('completed')
|
||||
|
||||
fire('execution_start', 'job-6')
|
||||
storeJob(store, 'job-6', wf)
|
||||
expect(store.getWorkflowStatus(wf)).toBe('completed')
|
||||
})
|
||||
|
||||
it('returns undefined for a null or unknown workflow', () => {
|
||||
const store = setup()
|
||||
expect(store.getWorkflowStatus(null)).toBeUndefined()
|
||||
expect(store.getWorkflowStatus(workflow('unknown.json'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
import { releaseSharedObjectUrl } from '@/utils/objectUrlUtil'
|
||||
@@ -71,20 +71,6 @@ describe('jobPreviewStore', () => {
|
||||
expect(store.previewsByPromptId).toEqual({ p2: 'blob:b' })
|
||||
})
|
||||
|
||||
it('ignores clearPreview without a prompt id', () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
vi.mocked(releaseSharedObjectUrl).mockClear()
|
||||
|
||||
store.clearPreview(undefined)
|
||||
|
||||
expect(store.nodePreviewsByPromptId['p1']).toEqual({
|
||||
url: 'blob:a',
|
||||
nodeId: 'node-1'
|
||||
})
|
||||
expect(releaseSharedObjectUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears all previews', () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
@@ -105,24 +91,6 @@ describe('jobPreviewStore', () => {
|
||||
expect(releaseSharedObjectUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores missing prompt ids', () => {
|
||||
const store = useJobPreviewStore()
|
||||
|
||||
store.setPreviewUrl(undefined, 'blob:a', 'node-1')
|
||||
|
||||
expect(store.nodePreviewsByPromptId).toEqual({})
|
||||
})
|
||||
|
||||
it('releases the old url when replacing a preview', () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
|
||||
store.setPreviewUrl('p1', 'blob:b', 'node-1')
|
||||
|
||||
expect(releaseSharedObjectUrl).toHaveBeenCalledWith('blob:a')
|
||||
expect(store.nodePreviewsByPromptId['p1']?.url).toBe('blob:b')
|
||||
})
|
||||
|
||||
it('ignores setPreviewUrl when previews are disabled', () => {
|
||||
previewMethodRef.value = 'none'
|
||||
const store = useJobPreviewStore()
|
||||
@@ -131,15 +99,4 @@ describe('jobPreviewStore', () => {
|
||||
|
||||
expect(store.nodePreviewsByPromptId).toEqual({})
|
||||
})
|
||||
|
||||
it('clears previews when previews are disabled after storage', async () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
|
||||
previewMethodRef.value = 'none'
|
||||
await nextTick()
|
||||
|
||||
expect(store.nodePreviewsByPromptId).toEqual({})
|
||||
expect(releaseSharedObjectUrl).toHaveBeenCalledWith('blob:a')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (path: string) => `http://localhost:8188${path}`,
|
||||
addEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
// Importing ResultItemImpl transitively loads @/scripts/app, whose module-level
|
||||
// ComfyApp singleton wires real listeners. Stub it; ResultItemImpl needs none of it.
|
||||
vi.mock('@/scripts/app', () => ({ app: {} }))
|
||||
|
||||
// Keep preview-url assertions deterministic: don't append cloud params.
|
||||
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
|
||||
appendCloudResParam: () => {}
|
||||
}))
|
||||
|
||||
interface ItemOverrides {
|
||||
filename?: string
|
||||
mediaType?: string
|
||||
format?: string
|
||||
frame_rate?: number
|
||||
}
|
||||
|
||||
function item(over: ItemOverrides = {}) {
|
||||
return new ResultItemImpl({
|
||||
filename: over.filename ?? 'out.png',
|
||||
subfolder: 'sub',
|
||||
type: 'output',
|
||||
nodeId: '1' as SerializedNodeId,
|
||||
mediaType: over.mediaType ?? 'images',
|
||||
format: over.format,
|
||||
frame_rate: over.frame_rate
|
||||
})
|
||||
}
|
||||
|
||||
describe('ResultItemImpl', () => {
|
||||
it('builds view url params and omits absent vhs fields', () => {
|
||||
const params = item({ filename: 'a.png' }).urlParams
|
||||
expect(params.get('filename')).toBe('a.png')
|
||||
expect(params.get('type')).toBe('output')
|
||||
expect(params.get('subfolder')).toBe('sub')
|
||||
expect(params.has('format')).toBe(false)
|
||||
expect(params.has('frame_rate')).toBe(false)
|
||||
})
|
||||
|
||||
it('includes vhs format and frame_rate params when present', () => {
|
||||
const params = item({ format: 'video/h264-mp4', frame_rate: 24 }).urlParams
|
||||
expect(params.get('format')).toBe('video/h264-mp4')
|
||||
expect(params.get('frame_rate')).toBe('24')
|
||||
})
|
||||
|
||||
it('returns an empty url for a nameless item and a view url otherwise', () => {
|
||||
expect(item({ filename: '' }).url).toBe('')
|
||||
expect(item({ filename: 'a.png' }).url).toContain('/view?')
|
||||
})
|
||||
|
||||
it('routes image preview urls through /view', () => {
|
||||
expect(
|
||||
item({ filename: 'a.png', mediaType: 'images' }).previewUrl
|
||||
).toContain('/view?')
|
||||
})
|
||||
|
||||
it('falls back to url directly for non-image preview urls', () => {
|
||||
const nonImage = item({ filename: 'a.mp3', mediaType: 'audio' })
|
||||
expect(nonImage.previewUrl).toBe(nonImage.url)
|
||||
})
|
||||
|
||||
it('exposes the vhs advanced preview endpoint', () => {
|
||||
expect(item().vhsAdvancedPreviewUrl).toContain('/viewvideo?')
|
||||
})
|
||||
|
||||
it('maps html video mime types by suffix and vhs format', () => {
|
||||
expect(item({ filename: 'a.webm' }).htmlVideoType).toBe('video/webm')
|
||||
expect(item({ filename: 'a.mp4' }).htmlVideoType).toBe('video/mp4')
|
||||
expect(item({ filename: 'a.mov' }).htmlVideoType).toBe('video/quicktime')
|
||||
expect(
|
||||
item({ filename: 'a.bin', format: 'video/mp4', frame_rate: 24 })
|
||||
.htmlVideoType
|
||||
).toBe('video/mp4')
|
||||
expect(item({ filename: 'a.txt' }).htmlVideoType).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps html audio mime types by suffix', () => {
|
||||
expect(item({ filename: 'a.mp3' }).htmlAudioType).toBe('audio/mpeg')
|
||||
expect(item({ filename: 'a.wav' }).htmlAudioType).toBe('audio/wav')
|
||||
expect(item({ filename: 'a.ogg' }).htmlAudioType).toBe('audio/ogg')
|
||||
expect(item({ filename: 'a.flac' }).htmlAudioType).toBe('audio/flac')
|
||||
expect(item({ filename: 'a.png' }).htmlAudioType).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats vhs format as such only with both format and frame_rate', () => {
|
||||
expect(item({ format: 'video/mp4', frame_rate: 24 }).isVhsFormat).toBe(true)
|
||||
expect(item({ format: 'video/mp4' }).isVhsFormat).toBe(false)
|
||||
expect(item({ frame_rate: 24 }).isVhsFormat).toBe(false)
|
||||
expect(item().isVhsFormat).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies video by suffix and by media type', () => {
|
||||
expect(item({ filename: 'a.webm' }).isVideo).toBe(true)
|
||||
expect(item({ filename: 'a.bin', mediaType: 'video' }).isVideo).toBe(true)
|
||||
expect(item({ filename: 'a.png', mediaType: 'video' }).isVideo).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies image only when not contradicted by a media suffix', () => {
|
||||
expect(item({ filename: 'a.png', mediaType: 'images' }).isImage).toBe(true)
|
||||
expect(item({ filename: 'a.webm', mediaType: 'images' }).isImage).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('classifies audio by suffix and by media type', () => {
|
||||
expect(item({ filename: 'a.mp3' }).isAudio).toBe(true)
|
||||
expect(item({ filename: 'a.bin', mediaType: 'audio' }).isAudio).toBe(true)
|
||||
expect(item({ filename: 'a.png', mediaType: 'audio' }).isAudio).toBe(false)
|
||||
})
|
||||
|
||||
it('reports text and preview support', () => {
|
||||
expect(item({ mediaType: 'text' }).isText).toBe(true)
|
||||
expect(item({ filename: 'a.png' }).supportsPreview).toBe(true)
|
||||
expect(
|
||||
item({ filename: 'a.bin', mediaType: 'binary' }).supportsPreview
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('filters previewable outputs and finds an item by url', () => {
|
||||
const png = item({ filename: 'a.png' })
|
||||
const bin = item({ filename: 'a.bin', mediaType: 'binary' })
|
||||
expect(ResultItemImpl.filterPreviewable([png, bin])).toEqual([png])
|
||||
|
||||
// A genuine match returns the matched index (1 here, distinguishing it
|
||||
// from the index-0 fallback used for no-match and missing-url cases).
|
||||
expect(ResultItemImpl.findByUrl([bin, png], png.url)).toBe(1)
|
||||
expect(ResultItemImpl.findByUrl([bin, png], 'no-match')).toBe(0)
|
||||
expect(ResultItemImpl.findByUrl([bin, png])).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,216 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ResultItemType, TaskOutput } from '@/schemas/apiSchema'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (path: string) => `http://localhost:8188${path}`,
|
||||
addEventListener: () => {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: {} }))
|
||||
|
||||
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
|
||||
appendCloudResParam: () => {}
|
||||
}))
|
||||
|
||||
const { parseTaskOutput } = vi.hoisted(() => ({ parseTaskOutput: vi.fn() }))
|
||||
vi.mock('@/stores/resultItemParsing', () => ({ parseTaskOutput }))
|
||||
|
||||
type JobStatus =
|
||||
| 'in_progress'
|
||||
| 'pending'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
|
||||
function executionError(
|
||||
overrides: Partial<NonNullable<JobListItem['execution_error']>> = {}
|
||||
): NonNullable<JobListItem['execution_error']> {
|
||||
return {
|
||||
node_id: '1',
|
||||
node_type: 'KSampler',
|
||||
exception_message: 'boom',
|
||||
exception_type: 'Error',
|
||||
traceback: [],
|
||||
current_inputs: {},
|
||||
current_outputs: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function job(over: Partial<JobListItem> = {}): JobListItem {
|
||||
return {
|
||||
id: 'job-1',
|
||||
status: 'completed',
|
||||
create_time: 1000,
|
||||
priority: 0,
|
||||
...over
|
||||
}
|
||||
}
|
||||
|
||||
function result(filename: string, type: ResultItemType = 'output') {
|
||||
return new ResultItemImpl({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type,
|
||||
nodeId: '1' as SerializedNodeId,
|
||||
mediaType: 'images'
|
||||
})
|
||||
}
|
||||
|
||||
describe('TaskItemImpl', () => {
|
||||
it('maps job status to taskType and apiTaskType', () => {
|
||||
expect(new TaskItemImpl(job({ status: 'in_progress' })).taskType).toBe(
|
||||
'Running'
|
||||
)
|
||||
expect(new TaskItemImpl(job({ status: 'pending' })).taskType).toBe(
|
||||
'Pending'
|
||||
)
|
||||
expect(new TaskItemImpl(job({ status: 'completed' })).taskType).toBe(
|
||||
'History'
|
||||
)
|
||||
|
||||
expect(new TaskItemImpl(job({ status: 'pending' })).apiTaskType).toBe(
|
||||
'queue'
|
||||
)
|
||||
expect(new TaskItemImpl(job({ status: 'completed' })).apiTaskType).toBe(
|
||||
'history'
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes displayStatus for every backend status', () => {
|
||||
const statuses: [JobStatus, string][] = [
|
||||
['in_progress', 'Running'],
|
||||
['pending', 'Pending'],
|
||||
['completed', 'Completed'],
|
||||
['failed', 'Failed'],
|
||||
['cancelled', 'Cancelled']
|
||||
]
|
||||
for (const [status, display] of statuses) {
|
||||
expect(new TaskItemImpl(job({ status })).displayStatus).toBe(display)
|
||||
}
|
||||
})
|
||||
|
||||
it('derives history/running flags and a status-qualified key', () => {
|
||||
const running = new TaskItemImpl(job({ id: 'a', status: 'in_progress' }))
|
||||
expect(running.isRunning).toBe(true)
|
||||
expect(running.isHistory).toBe(false)
|
||||
expect(running.key).toBe('aRunning')
|
||||
|
||||
expect(new TaskItemImpl(job({ status: 'completed' })).isHistory).toBe(true)
|
||||
})
|
||||
|
||||
it('uses explicitly provided flat outputs', () => {
|
||||
const outputs = [result('a.png')]
|
||||
const task = new TaskItemImpl(job(), undefined, outputs)
|
||||
expect(task.flatOutputs).toBe(outputs)
|
||||
})
|
||||
|
||||
it('parses outputs lazily when flat outputs are not supplied', () => {
|
||||
const parsed = [result('p.png')]
|
||||
parseTaskOutput.mockReturnValueOnce(parsed)
|
||||
const outputs: TaskOutput = { '1': { images: [] } }
|
||||
const task = new TaskItemImpl(job(), outputs)
|
||||
expect(parseTaskOutput).toHaveBeenCalled()
|
||||
expect(task.flatOutputs).toBe(parsed)
|
||||
})
|
||||
|
||||
it('synthesizes outputs from preview_output when none are provided', () => {
|
||||
parseTaskOutput.mockReturnValueOnce([])
|
||||
const preview = { nodeId: '5', mediaType: 'images', filename: 'prev.png' }
|
||||
new TaskItemImpl(job({ preview_output: preview }))
|
||||
expect(parseTaskOutput).toHaveBeenCalledWith({
|
||||
'5': { images: [preview] }
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the last saved output over temp previews for previewOutput', () => {
|
||||
const temp = result('temp.png', 'temp')
|
||||
const saved = result('saved.png', 'output')
|
||||
const task = new TaskItemImpl(job(), undefined, [temp, saved])
|
||||
expect(task.previewOutput).toBe(saved)
|
||||
|
||||
const onlyTemp = new TaskItemImpl(job(), undefined, [temp])
|
||||
expect(onlyTemp.previewOutput).toBe(temp)
|
||||
})
|
||||
|
||||
it('reports interrupted only for an interrupt-typed failure', () => {
|
||||
expect(
|
||||
new TaskItemImpl(
|
||||
job({
|
||||
status: 'failed',
|
||||
execution_error: executionError({
|
||||
exception_type: 'InterruptProcessingException'
|
||||
})
|
||||
})
|
||||
).interrupted
|
||||
).toBe(true)
|
||||
expect(
|
||||
new TaskItemImpl(
|
||||
job({
|
||||
status: 'failed',
|
||||
execution_error: executionError({ exception_type: 'Other' })
|
||||
})
|
||||
).interrupted
|
||||
).toBe(false)
|
||||
expect(
|
||||
new TaskItemImpl(
|
||||
job({
|
||||
status: 'completed',
|
||||
execution_error: executionError({
|
||||
exception_type: 'InterruptProcessingException'
|
||||
})
|
||||
})
|
||||
).interrupted
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces error message and passthrough job fields', () => {
|
||||
const task = new TaskItemImpl(
|
||||
job({
|
||||
status: 'failed',
|
||||
outputs_count: 3,
|
||||
workflow_id: 'wf-9',
|
||||
execution_error: executionError({ exception_message: 'boom' })
|
||||
})
|
||||
)
|
||||
expect(task.errorMessage).toBe('boom')
|
||||
expect(task.outputsCount).toBe(3)
|
||||
expect(task.workflowId).toBe('wf-9')
|
||||
})
|
||||
|
||||
it('computes execution time only when both timestamps exist', () => {
|
||||
expect(
|
||||
new TaskItemImpl(
|
||||
job({ execution_start_time: 1000, execution_end_time: 3000 })
|
||||
).executionTimeInSeconds
|
||||
).toBe(2)
|
||||
expect(
|
||||
new TaskItemImpl(job({ execution_start_time: 1000 })).executionTime
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('flatten returns itself when not completed', () => {
|
||||
const running = new TaskItemImpl(job({ status: 'in_progress' }))
|
||||
expect(running.flatten()).toEqual([running])
|
||||
})
|
||||
|
||||
it('flatten expands a completed task into one task per output', () => {
|
||||
const outputs = [result('a.png'), result('b.png')]
|
||||
const task = new TaskItemImpl(
|
||||
job({ id: 'j', status: 'completed' }),
|
||||
undefined,
|
||||
outputs
|
||||
)
|
||||
|
||||
const flattened = task.flatten()
|
||||
|
||||
expect(flattened).toHaveLength(2)
|
||||
expect(flattened.map((t) => t.jobId)).toEqual(['j-0', 'j-1'])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
|
||||
@@ -154,22 +154,6 @@ describe(parseNodeOutput, () => {
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].filename).toBe('valid.png')
|
||||
})
|
||||
|
||||
it('excludes non-object and invalid-type items', () => {
|
||||
const output = fromAny<NodeExecutionOutput, unknown>({
|
||||
images: [
|
||||
null,
|
||||
'not-an-item',
|
||||
{ filename: 'bad.png', type: 'invalid' },
|
||||
{ filename: 'valid.png', type: 'output' }
|
||||
]
|
||||
})
|
||||
|
||||
const result = parseNodeOutput('1', output)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].filename).toBe('valid.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe(parseTaskOutput, () => {
|
||||
|
||||
Reference in New Issue
Block a user