Compare commits

..

3 Commits

Author SHA1 Message Date
huang47
2251e100cc test: honor explicit undefined timestamp in queue banner task helper 2026-07-02 14:48:30 -07:00
huang47
ae34b4eafa test: address coderabbit review feedback 2026-07-02 14:43:51 -07:00
huang47
f58ec5ccf6 test: cover queue and execution stores 2026-07-02 14:26:19 -07:00
24 changed files with 2037 additions and 320 deletions

View File

@@ -0,0 +1,132 @@
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()
})
})

View File

@@ -280,6 +280,20 @@ 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]) => {
@@ -393,6 +407,26 @@ 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(
@@ -544,6 +578,74 @@ 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(
@@ -594,6 +696,45 @@ 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)
@@ -618,6 +759,17 @@ 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')
@@ -713,6 +865,24 @@ 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()

View File

@@ -79,12 +79,10 @@ describe(useQueueNotificationBanners, () => {
isImage?: boolean
} = {}
): MockTask => {
const {
state = 'Completed',
ts = Date.now(),
previewUrl,
isImage = true
} = options
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 task: MockTask = {
displayStatus: state,
@@ -186,6 +184,75 @@ 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()
@@ -306,6 +373,64 @@ 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()

View File

@@ -63,8 +63,7 @@ function buildResponse(
return {
ok: init.ok ?? true,
status: init.status ?? 200,
json: vi.fn().mockResolvedValue(body),
text: vi.fn().mockResolvedValue(JSON.stringify(body))
json: vi.fn().mockResolvedValue(body)
} as unknown as Response
}

View File

@@ -24,7 +24,6 @@ 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
@@ -693,8 +692,10 @@ function createAssetService() {
)
if (!res.ok) {
const { code } = await parseErrorResponse(res)
throw new Error(getLocalizedErrorMessage(code))
const errorData = await res.json().catch(() => ({}))
throw new Error(
getLocalizedErrorMessage(errorData.code || 'UNKNOWN_ERROR')
)
}
const data: AssetMetadata = await res.json()

View File

@@ -21,7 +21,6 @@ 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,
@@ -330,10 +329,10 @@ function useSubscriptionInternal() {
)
if (!response.ok) {
const { message } = await parseErrorResponse(response)
const errorData = await response.json()
throw new AuthStoreError(
t('toastMessages.failedToFetchSubscription', {
error: message
error: errorData.message
})
)
}
@@ -423,10 +422,10 @@ function useSubscriptionInternal() {
)
if (!response.ok) {
const { message } = await parseErrorResponse(response)
const errorData = await response.json()
throw new AuthStoreError(
t('toastMessages.failedToInitiateSubscription', {
error: message
error: errorData.message
})
)
}

View File

@@ -16,9 +16,7 @@ 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`
@@ -99,11 +97,24 @@ export async function performSubscriptionCheckout(
)
if (!response.ok) {
const { message } = await parseErrorResponse(response)
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}`
}
}
throw new AuthStoreError(
t('toastMessages.failedToInitiateSubscription', {
error: message
error: errorMessage
})
)
}

View File

@@ -1,185 +0,0 @@
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'
})
})
})

View File

@@ -1,73 +0,0 @@
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)
}

View File

@@ -1,5 +1,4 @@
import { api } from '@/scripts/api'
import { parseErrorResponse } from '@/platform/remote/comfyui/errors'
import type {
SecretCreateRequest,
@@ -13,6 +12,11 @@ interface ListSecretsResponse {
data: SecretMetadata[]
}
interface ErrorResponse {
message?: string
code?: string
}
export class SecretsApiError extends Error {
constructor(
message: string,
@@ -26,13 +30,22 @@ export class SecretsApiError extends Error {
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorData = await parseErrorResponse(response)
let errorData: ErrorResponse = {}
try {
errorData = await response.json()
} catch {
// Response body is not JSON
}
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.status, code)
throw new SecretsApiError(
errorData.message ?? response.statusText,
response.status,
code
)
}
return response.json()
}

View File

@@ -9,7 +9,6 @@ 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'
@@ -352,7 +351,7 @@ async function getAuthHeaderOrThrow() {
function handleAxiosError(err: unknown): never {
if (axios.isAxiosError(err)) {
const status = err.response?.status
const { message } = errorResponseFromBody(err.response?.data, err.message)
const message = err.response?.data?.message ?? err.message
throw new WorkspaceApiError(message, status)
}
throw err

View File

@@ -341,8 +341,7 @@ describe('useWorkspaceAuthStore', () => {
ok: false,
status: 403,
statusText: 'Forbidden',
text: () =>
Promise.resolve(JSON.stringify({ message: 'Access denied' }))
json: () => Promise.resolve({ message: 'Access denied' })
})
)
@@ -365,8 +364,7 @@ describe('useWorkspaceAuthStore', () => {
ok: false,
status: 404,
statusText: 'Not Found',
text: () =>
Promise.resolve(JSON.stringify({ message: 'Workspace not found' }))
json: () => Promise.resolve({ message: 'Workspace not found' })
})
)
@@ -391,8 +389,7 @@ describe('useWorkspaceAuthStore', () => {
ok: false,
status: 401,
statusText: 'Unauthorized',
text: () =>
Promise.resolve(JSON.stringify({ message: 'Invalid token' }))
json: () => Promise.resolve({ message: 'Invalid token' })
})
)
@@ -417,8 +414,7 @@ describe('useWorkspaceAuthStore', () => {
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: () =>
Promise.resolve(JSON.stringify({ message: 'Server error' }))
json: () => Promise.resolve({ message: 'Server error' })
})
)
@@ -635,8 +631,7 @@ describe('useWorkspaceAuthStore', () => {
ok: false,
status: 403,
statusText: 'Forbidden',
text: () =>
Promise.resolve(JSON.stringify({ message: 'Access denied' }))
json: () => Promise.resolve({ message: 'Access denied' })
})
vi.stubGlobal('fetch', mockFetch)
@@ -810,7 +805,7 @@ describe('useWorkspaceAuthStore', () => {
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: () => Promise.resolve(JSON.stringify({ message: 'Server error' }))
json: () => Promise.resolve({ message: 'Server error' })
})
const consoleErrorSpy = vi
@@ -902,8 +897,7 @@ describe('useWorkspaceAuthStore', () => {
ok: false,
status: 401,
statusText: 'Unauthorized',
text: () =>
Promise.resolve(JSON.stringify({ message: 'Invalid token' }))
json: () => Promise.resolve({ message: 'Invalid token' })
})
const consoleErrorSpy = vi
@@ -945,8 +939,7 @@ describe('useWorkspaceAuthStore', () => {
ok: false,
status: 403,
statusText: 'Forbidden',
text: () =>
Promise.resolve(JSON.stringify({ message: 'Access denied' }))
json: () => Promise.resolve({ message: 'Access denied' })
})
await expect(store.switchWorkspace('workspace-other')).rejects.toThrow(
WorkspaceAuthError
@@ -1218,7 +1211,7 @@ describe('useWorkspaceAuthStore', () => {
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: () => Promise.resolve(JSON.stringify({ message: 'Server error' }))
json: () => Promise.resolve({ message: 'Server error' })
})
await refreshPromise
@@ -1482,8 +1475,7 @@ describe('useWorkspaceAuthStore', () => {
ok: false,
status: 401,
statusText: 'Unauthorized',
text: () =>
Promise.resolve(JSON.stringify({ message: 'Invalid token' }))
json: () => Promise.resolve({ message: 'Invalid token' })
})
vi.stubGlobal('fetch', mockFetch)
@@ -1522,7 +1514,7 @@ describe('useWorkspaceAuthStore', () => {
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: () => Promise.resolve(JSON.stringify({ message: 'try again' }))
json: () => Promise.resolve({ message: 'try again' })
})
vi.stubGlobal('fetch', mockFetch)
@@ -1694,7 +1686,7 @@ describe('useWorkspaceAuthStore', () => {
ok: false,
status,
statusText,
text: () => Promise.resolve(JSON.stringify({ message: statusText }))
json: () => Promise.resolve({ message: statusText })
})
vi.stubGlobal('fetch', mockFetch)
@@ -1789,7 +1781,7 @@ describe('useWorkspaceAuthStore', () => {
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: () => Promise.resolve(JSON.stringify({ message: 'try again' }))
json: () => Promise.resolve({ message: 'try again' })
})
vi.stubGlobal('fetch', mockFetch)
@@ -1812,8 +1804,7 @@ describe('useWorkspaceAuthStore', () => {
ok: false,
status: 401,
statusText: 'Unauthorized',
text: () =>
Promise.resolve(JSON.stringify({ message: 'Invalid token' }))
json: () => Promise.resolve({ message: 'Invalid token' })
})
vi.stubGlobal('fetch', mockFetch)

View File

@@ -12,7 +12,6 @@ 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'
@@ -313,7 +312,8 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
})
if (!response.ok) {
const { message } = await parseErrorResponse(response)
const errorData = await response.json().catch(() => ({}))
const message = errorData.message || response.statusText
if (response.status === 401) {
throw new WorkspaceAuthError(

View File

@@ -35,7 +35,6 @@ 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 =
@@ -309,10 +308,10 @@ export const useAuthStore = defineStore('auth', () => {
// Customer not found is expected for new users
return null
}
const { message } = await parseErrorResponse(response)
const errorData = await response.json()
throw new AuthStoreError(
t('toastMessages.failedToFetchBalance', {
error: message
error: errorData.message
})
)
}
@@ -551,10 +550,10 @@ export const useAuthStore = defineStore('auth', () => {
)
if (!response.ok) {
const { message } = await parseErrorResponse(response)
const errorData = await response.json()
throw new AuthStoreError(
t('toastMessages.failedToInitiateCreditPurchase', {
error: message
error: errorData.message
})
)
}
@@ -591,10 +590,10 @@ export const useAuthStore = defineStore('auth', () => {
)
if (!response.ok) {
const { message } = await parseErrorResponse(response)
const errorData = await response.json()
throw new AuthStoreError(
t('toastMessages.failedToAccessBillingPortal', {
error: message
error: errorData.message
})
)
}

View File

@@ -0,0 +1,120 @@
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)
})
})

View File

@@ -0,0 +1,119 @@
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)
})
})

View File

@@ -0,0 +1,129 @@
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'])
})
})

View File

@@ -0,0 +1,173 @@
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: ''
})
})
})

View File

@@ -22,7 +22,8 @@ const {
mockShowTextPreview,
mockTrackExecutionError,
mockTrackExecutionSuccess,
mockTrackSharedWorkflowRun
mockTrackSharedWorkflowRun,
mockRevokePreviewsByExecutionId
} = await vi.hoisted(async () => {
const { shallowRef } = await import('vue')
return {
@@ -34,7 +35,8 @@ const {
mockShowTextPreview: vi.fn(),
mockTrackExecutionError: vi.fn(),
mockTrackExecutionSuccess: vi.fn(),
mockTrackSharedWorkflowRun: vi.fn()
mockTrackSharedWorkflowRun: vi.fn(),
mockRevokePreviewsByExecutionId: vi.fn()
}
})
@@ -129,7 +131,7 @@ vi.mock('@/scripts/api', () => ({
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({
revokePreviewsByExecutionId: vi.fn()
revokePreviewsByExecutionId: mockRevokePreviewsByExecutionId
})
}))
@@ -423,6 +425,124 @@ 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', () => {
@@ -551,6 +671,33 @@ 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', () => {
@@ -675,6 +822,16 @@ 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')
@@ -691,6 +848,14 @@ 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.
@@ -900,6 +1065,35 @@ 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')
@@ -915,6 +1109,19 @@ 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', () => {
@@ -1375,6 +1582,21 @@ 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', () => {
@@ -1562,9 +1784,35 @@ 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 = {
@@ -1590,7 +1838,34 @@ 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' }
@@ -1610,6 +1885,24 @@ 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', () => {
@@ -1631,6 +1924,40 @@ 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()
@@ -1744,6 +2071,12 @@ 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', () => {
@@ -1813,6 +2146,45 @@ 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')
@@ -1829,4 +2201,58 @@ 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)
})
})

View File

@@ -0,0 +1,153 @@
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()
})
})

View File

@@ -1,6 +1,6 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import { releaseSharedObjectUrl } from '@/utils/objectUrlUtil'
@@ -71,6 +71,20 @@ 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')
@@ -91,6 +105,24 @@ 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()
@@ -99,4 +131,15 @@ 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')
})
})

View File

@@ -0,0 +1,141 @@
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)
})
})

View File

@@ -0,0 +1,216 @@
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'])
})
})

View File

@@ -1,4 +1,4 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
@@ -154,6 +154,22 @@ 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, () => {