mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-01 11:10:00 +00:00
refactor: encapsulate error extraction in TaskItemImpl getters (#7650)
## Summary - Add `errorMessage` and `executionError` getters to `TaskItemImpl` that extract error info from status messages - Update `useJobErrorReporting` composable to use these getters instead of standalone function - Remove the standalone `extractExecutionError` function This encapsulates error extraction within `TaskItemImpl`, preparing for the Jobs API migration where the underlying data format will change but the getter interface will remain stable. ## Test plan - [x] All existing tests pass - [x] New tests added for `TaskItemImpl.errorMessage` and `TaskItemImpl.executionError` getters - [x] TypeScript, lint, and knip checks pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7650-refactor-encapsulate-error-extraction-in-TaskItemImpl-getters-2ce6d73d365081caae33dcc7e1e07720) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
@@ -16,7 +16,7 @@ type TestTask = {
|
||||
executionTime?: number
|
||||
executionEndTimestamp?: number
|
||||
createTime?: number
|
||||
workflow?: { id?: string }
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
@@ -161,7 +161,7 @@ const createTask = (
|
||||
executionTime: overrides.executionTime,
|
||||
executionEndTimestamp: overrides.executionEndTimestamp,
|
||||
createTime: overrides.createTime,
|
||||
workflow: overrides.workflow
|
||||
workflowId: overrides.workflowId
|
||||
})
|
||||
|
||||
const mountUseJobList = () => {
|
||||
@@ -305,7 +305,7 @@ describe('useJobList', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('sorts all tasks by queue index descending', async () => {
|
||||
it('sorts all tasks by priority descending', async () => {
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
|
||||
]
|
||||
@@ -360,13 +360,13 @@ describe('useJobList', () => {
|
||||
promptId: 'wf-1',
|
||||
queueIndex: 2,
|
||||
mockState: 'pending',
|
||||
workflow: { id: 'workflow-1' }
|
||||
workflowId: 'workflow-1'
|
||||
}),
|
||||
createTask({
|
||||
promptId: 'wf-2',
|
||||
queueIndex: 1,
|
||||
mockState: 'pending',
|
||||
workflow: { id: 'workflow-2' }
|
||||
workflowId: 'workflow-2'
|
||||
})
|
||||
]
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ export function useJobList() {
|
||||
const activeId = workflowStore.activeWorkflow?.activeState?.id
|
||||
if (!activeId) return []
|
||||
entries = entries.filter(({ task }) => {
|
||||
const wid = task.workflow?.id
|
||||
const wid = task.workflowId
|
||||
return !!wid && wid === activeId
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ vi.mock('@/scripts/utils', () => ({
|
||||
}))
|
||||
|
||||
const dialogServiceMock = {
|
||||
showErrorDialog: vi.fn(),
|
||||
showExecutionErrorDialog: vi.fn(),
|
||||
prompt: vi.fn()
|
||||
}
|
||||
@@ -103,6 +104,11 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => queueStoreMock
|
||||
}))
|
||||
|
||||
const getJobWorkflowMock = vi.fn()
|
||||
vi.mock('@/services/jobOutputCache', () => ({
|
||||
getJobWorkflow: (...args: any[]) => getJobWorkflowMock(...args)
|
||||
}))
|
||||
|
||||
const createAnnotatedPathMock = vi.fn()
|
||||
vi.mock('@/utils/createAnnotatedPath', () => ({
|
||||
createAnnotatedPath: (...args: any[]) => createAnnotatedPathMock(...args)
|
||||
@@ -132,9 +138,7 @@ const createJobItem = (
|
||||
title: overrides.title ?? 'Test job',
|
||||
meta: overrides.meta ?? 'meta',
|
||||
state: overrides.state ?? 'completed',
|
||||
taskRef: overrides.taskRef as Partial<TaskItemImpl> | undefined as
|
||||
| TaskItemImpl
|
||||
| undefined,
|
||||
taskRef: overrides.taskRef as TaskItemImpl | undefined,
|
||||
iconName: overrides.iconName,
|
||||
iconImageUrl: overrides.iconImageUrl,
|
||||
showClear: overrides.showClear,
|
||||
@@ -181,6 +185,8 @@ describe('useJobMenu', () => {
|
||||
LoadVideo: { id: 'LoadVideo' },
|
||||
LoadAudio: { id: 'LoadAudio' }
|
||||
}
|
||||
// Default: no workflow available via lazy loading
|
||||
getJobWorkflowMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
const setCurrentItem = (item: JobListItem | null) => {
|
||||
@@ -190,10 +196,13 @@ describe('useJobMenu', () => {
|
||||
it('opens workflow when workflow data exists', async () => {
|
||||
const { openJobWorkflow } = mountJobMenu()
|
||||
const workflow = { nodes: [] }
|
||||
setCurrentItem(createJobItem({ id: '55', taskRef: { workflow } }))
|
||||
// Mock lazy loading via fetchJobDetail + extractWorkflow
|
||||
getJobWorkflowMock.mockResolvedValue(workflow)
|
||||
setCurrentItem(createJobItem({ id: '55' }))
|
||||
|
||||
await openJobWorkflow()
|
||||
|
||||
expect(getJobWorkflowMock).toHaveBeenCalledWith('55')
|
||||
expect(workflowStoreMock.createTemporary).toHaveBeenCalledWith(
|
||||
'Job 55.json',
|
||||
workflow
|
||||
@@ -268,11 +277,10 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('copies error message from failed job entry', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
const error = { exception_message: 'boom' }
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { status: { messages: [['execution_error', error]] } } as any
|
||||
taskRef: { errorMessage: 'Something went wrong' } as any
|
||||
})
|
||||
)
|
||||
|
||||
@@ -280,31 +288,75 @@ describe('useJobMenu', () => {
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'copy-error')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith('boom')
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('reports error via dialog when entry triggered', async () => {
|
||||
it('reports error via rich dialog when execution_error available', async () => {
|
||||
const executionError = {
|
||||
prompt_id: 'job-1',
|
||||
timestamp: 12345,
|
||||
node_id: '5',
|
||||
node_type: 'KSampler',
|
||||
executed: ['1', '2'],
|
||||
exception_message: 'CUDA out of memory',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: ['line 1', 'line 2'],
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
}
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
const error = { exception_message: 'bad', extra: 1 }
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { status: { messages: [['execution_error', error]] } } as any
|
||||
taskRef: {
|
||||
errorMessage: 'CUDA out of memory',
|
||||
executionError,
|
||||
createTime: 12345
|
||||
} as any
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
void entry?.onClick?.()
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledTimes(1)
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledWith(
|
||||
error
|
||||
executionError
|
||||
)
|
||||
expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to simple error dialog when no execution_error', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { errorMessage: 'Job failed with error' } as any
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
expect(dialogServiceMock.showErrorDialog).toHaveBeenCalledTimes(1)
|
||||
const [errorArg, optionsArg] =
|
||||
dialogServiceMock.showErrorDialog.mock.calls[0]
|
||||
expect(errorArg).toBeInstanceOf(Error)
|
||||
expect(errorArg.message).toBe('Job failed with error')
|
||||
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
|
||||
})
|
||||
|
||||
it('ignores error actions when message missing', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'failed', taskRef: { status: {} } }))
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { errorMessage: undefined } as any
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const copyEntry = findActionEntry(jobMenuEntries.value, 'copy-error')
|
||||
@@ -313,6 +365,7 @@ describe('useJobMenu', () => {
|
||||
await reportEntry?.onClick?.()
|
||||
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled()
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -488,12 +541,13 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('exports workflow with default filename when prompting disabled', async () => {
|
||||
const workflow = { foo: 'bar' }
|
||||
getJobWorkflowMock.mockResolvedValue(workflow)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
id: '7',
|
||||
state: 'completed',
|
||||
taskRef: { workflow: { foo: 'bar' } }
|
||||
state: 'completed'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -513,11 +567,11 @@ describe('useJobMenu', () => {
|
||||
it('prompts for filename when setting enabled', async () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('custom-name')
|
||||
getJobWorkflowMock.mockResolvedValue({})
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { workflow: {} }
|
||||
state: 'completed'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -537,12 +591,12 @@ describe('useJobMenu', () => {
|
||||
it('keeps existing json extension when exporting workflow', async () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('existing.json')
|
||||
getJobWorkflowMock.mockResolvedValue({ foo: 'bar' })
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
id: '42',
|
||||
state: 'completed',
|
||||
taskRef: { workflow: { foo: 'bar' } }
|
||||
state: 'completed'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -558,11 +612,11 @@ describe('useJobMenu', () => {
|
||||
it('abandons export when prompt cancelled', async () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('')
|
||||
getJobWorkflowMock.mockResolvedValue({})
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { workflow: {} }
|
||||
state: 'completed'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -682,7 +736,12 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('returns failed menu entries with error actions', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'failed', taskRef: { status: {} } }))
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { errorMessage: 'Some error' } as any
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||
|
||||
@@ -9,15 +9,11 @@ import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAsse
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type {
|
||||
ExecutionErrorWsMessage,
|
||||
ResultItem,
|
||||
ResultItemType,
|
||||
TaskStatus
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { downloadBlob } from '@/scripts/utils'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { getJobWorkflow } from '@/services/jobOutputCache'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
@@ -59,7 +55,7 @@ export function useJobMenu(
|
||||
const openJobWorkflow = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const data = target.taskRef?.workflow
|
||||
const data = await getJobWorkflow(target.id)
|
||||
if (!data) return
|
||||
const filename = `Job ${target.id}.json`
|
||||
const temp = workflowStore.createTemporary(filename, data)
|
||||
@@ -83,37 +79,39 @@ export function useJobMenu(
|
||||
await queueStore.update()
|
||||
}
|
||||
|
||||
const findExecutionError = (
|
||||
messages: TaskStatus['messages'] | undefined
|
||||
): ExecutionErrorWsMessage | undefined => {
|
||||
const errMessage = messages?.find((m) => m[0] === 'execution_error')
|
||||
if (errMessage && errMessage[0] === 'execution_error') {
|
||||
return errMessage[1]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const copyErrorMessage = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const err = findExecutionError(target.taskRef?.status?.messages)
|
||||
const message = err?.exception_message
|
||||
if (message) await copyToClipboard(String(message))
|
||||
const message = target?.taskRef?.errorMessage
|
||||
if (message) await copyToClipboard(message)
|
||||
}
|
||||
|
||||
const reportError = (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const err = findExecutionError(target.taskRef?.status?.messages)
|
||||
if (err) useDialogService().showExecutionErrorDialog(err)
|
||||
|
||||
// Use execution_error from list response if available
|
||||
const executionError = target.taskRef?.executionError
|
||||
|
||||
if (executionError) {
|
||||
useDialogService().showExecutionErrorDialog(executionError)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to simple error dialog
|
||||
const message = target.taskRef?.errorMessage
|
||||
if (message) {
|
||||
useDialogService().showErrorDialog(new Error(message), {
|
||||
reportType: 'queueJobError'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// This is very magical only because it matches the respective backend implementation
|
||||
// There is or will be a better way to do this
|
||||
const addOutputLoaderNode = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
||||
const addOutputLoaderNode = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
|
||||
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
|
||||
@@ -161,10 +159,10 @@ export function useJobMenu(
|
||||
/**
|
||||
* Trigger a download of the job's previewable output asset.
|
||||
*/
|
||||
const downloadPreviewAsset = (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
||||
const downloadPreviewAsset = () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
downloadFile(result.url)
|
||||
}
|
||||
@@ -172,14 +170,14 @@ export function useJobMenu(
|
||||
/**
|
||||
* Export the workflow JSON attached to the job.
|
||||
*/
|
||||
const exportJobWorkflow = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const data = target.taskRef?.workflow
|
||||
const exportJobWorkflow = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const data = await getJobWorkflow(item.id)
|
||||
if (!data) return
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
let filename = `Job ${target.id}.json`
|
||||
let filename = `Job ${item.id}.json`
|
||||
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
const input = await useDialogService().prompt({
|
||||
@@ -196,10 +194,10 @@ export function useJobMenu(
|
||||
downloadBlob(filename, blob)
|
||||
}
|
||||
|
||||
const deleteJobAsset = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const task = target.taskRef as TaskItemImpl | undefined
|
||||
const deleteJobAsset = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const task = item.taskRef as TaskItemImpl | undefined
|
||||
const preview = task?.previewOutput
|
||||
if (!task || !preview) return
|
||||
|
||||
@@ -210,8 +208,8 @@ export function useJobMenu(
|
||||
}
|
||||
}
|
||||
|
||||
const removeFailedJob = async (item?: JobListItem | null) => {
|
||||
const task = resolveItem(item)?.taskRef as TaskItemImpl | undefined
|
||||
const removeFailedJob = async () => {
|
||||
const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined
|
||||
if (!task) return
|
||||
await queueStore.delete(task)
|
||||
}
|
||||
@@ -242,8 +240,8 @@ export function useJobMenu(
|
||||
icon: 'icon-[lucide--zoom-in]',
|
||||
onClick: onInspectAsset
|
||||
? () => {
|
||||
const current = resolveItem()
|
||||
if (current) onInspectAsset(current)
|
||||
const item = currentMenuItem()
|
||||
if (item) onInspectAsset(item)
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
@@ -254,33 +252,33 @@ export function useJobMenu(
|
||||
'Add to current workflow'
|
||||
),
|
||||
icon: 'icon-[comfy--node]',
|
||||
onClick: () => addOutputLoaderNode(resolveItem())
|
||||
onClick: addOutputLoaderNode
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: st('queue.jobMenu.download', 'Download'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
onClick: () => downloadPreviewAsset(resolveItem())
|
||||
onClick: downloadPreviewAsset
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: () => openJobWorkflow(resolveItem())
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{
|
||||
key: 'export-workflow',
|
||||
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
|
||||
icon: 'icon-[comfy--file-output]',
|
||||
onClick: () => exportJobWorkflow(resolveItem())
|
||||
onClick: exportJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyJobId(resolveItem())
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd3' },
|
||||
...(hasDeletableAsset
|
||||
@@ -289,7 +287,7 @@ export function useJobMenu(
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
onClick: () => deleteJobAsset(resolveItem())
|
||||
onClick: deleteJobAsset
|
||||
}
|
||||
]
|
||||
: [])
|
||||
@@ -301,33 +299,33 @@ export function useJobMenu(
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowFailedLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: () => openJobWorkflow(resolveItem())
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyJobId(resolveItem())
|
||||
onClick: copyJobId
|
||||
},
|
||||
{
|
||||
key: 'copy-error',
|
||||
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyErrorMessage(resolveItem())
|
||||
onClick: copyErrorMessage
|
||||
},
|
||||
{
|
||||
key: 'report-error',
|
||||
label: st('queue.jobMenu.reportError', 'Report error'),
|
||||
icon: 'icon-[lucide--message-circle-warning]',
|
||||
onClick: () => reportError(resolveItem())
|
||||
onClick: reportError
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.removeJob', 'Remove job'),
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
onClick: () => removeFailedJob(resolveItem())
|
||||
onClick: removeFailedJob
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -336,21 +334,21 @@ export function useJobMenu(
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: () => openJobWorkflow(resolveItem())
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyJobId(resolveItem())
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'cancel-job',
|
||||
label: jobMenuCancelLabel.value,
|
||||
icon: 'icon-[lucide--x]',
|
||||
onClick: () => cancelJob(resolveItem())
|
||||
onClick: cancelJob
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -1,35 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem as JobListViewItem } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
type PreviewLike = { url: string; supportsPreview: boolean }
|
||||
const createResultItem = (
|
||||
url: string,
|
||||
supportsPreview = true
|
||||
): ResultItemImpl => {
|
||||
const item = new ResultItemImpl({
|
||||
filename: url,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node-1',
|
||||
mediaType: supportsPreview ? 'images' : 'unknown'
|
||||
})
|
||||
// Override url getter for test matching
|
||||
Object.defineProperty(item, 'url', { get: () => url })
|
||||
Object.defineProperty(item, 'supportsPreview', { get: () => supportsPreview })
|
||||
return item
|
||||
}
|
||||
|
||||
const createPreview = (url: string, supportsPreview = true): PreviewLike => ({
|
||||
url,
|
||||
supportsPreview
|
||||
const createMockJob = (id: string, outputsCount = 1): JobListItem => ({
|
||||
id,
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
preview_output: null,
|
||||
outputs_count: outputsCount,
|
||||
priority: 0
|
||||
})
|
||||
|
||||
const createTask = (preview?: PreviewLike) => ({
|
||||
previewOutput: preview
|
||||
})
|
||||
const createTask = (
|
||||
preview?: ResultItemImpl,
|
||||
allOutputs?: ResultItemImpl[],
|
||||
outputsCount = 1
|
||||
): TaskItemImpl => {
|
||||
const job = createMockJob(
|
||||
`task-${Math.random().toString(36).slice(2)}`,
|
||||
outputsCount
|
||||
)
|
||||
const flatOutputs = allOutputs ?? (preview ? [preview] : [])
|
||||
return new TaskItemImpl(job, {}, flatOutputs)
|
||||
}
|
||||
|
||||
const createJobItem = (id: string, preview?: PreviewLike): JobListItem =>
|
||||
const createJobViewItem = (
|
||||
id: string,
|
||||
taskRef?: TaskItemImpl
|
||||
): JobListViewItem =>
|
||||
({
|
||||
id,
|
||||
title: `Job ${id}`,
|
||||
meta: '',
|
||||
state: 'completed',
|
||||
showClear: false,
|
||||
taskRef: preview ? { previewOutput: preview } : undefined
|
||||
}) as JobListItem
|
||||
taskRef
|
||||
}) as JobListViewItem
|
||||
|
||||
describe('useResultGallery', () => {
|
||||
it('collects only previewable outputs and preserves their order', () => {
|
||||
const previewable = [createPreview('p-1'), createPreview('p-2')]
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('collects only previewable outputs and preserves their order', async () => {
|
||||
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
|
||||
const nonPreviewable = createResultItem('skip-me', false)
|
||||
const tasks = [
|
||||
createTask(previewable[0]),
|
||||
createTask({ url: 'skip-me', supportsPreview: false }),
|
||||
createTask(nonPreviewable),
|
||||
createTask(previewable[1]),
|
||||
createTask()
|
||||
]
|
||||
@@ -38,28 +77,28 @@ describe('useResultGallery', () => {
|
||||
() => tasks
|
||||
)
|
||||
|
||||
onViewItem(createJobItem('job-1', previewable[0]))
|
||||
await onViewItem(createJobViewItem('job-1', tasks[0]))
|
||||
|
||||
expect(galleryItems.value).toEqual(previewable)
|
||||
expect(galleryItems.value).toEqual([previewable[0]])
|
||||
expect(galleryActiveIndex.value).toBe(0)
|
||||
})
|
||||
|
||||
it('does not change state when there are no previewable tasks', () => {
|
||||
it('does not change state when there are no previewable tasks', async () => {
|
||||
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
||||
() => []
|
||||
)
|
||||
|
||||
onViewItem(createJobItem('job-missing'))
|
||||
await onViewItem(createJobViewItem('job-missing'))
|
||||
|
||||
expect(galleryItems.value).toEqual([])
|
||||
expect(galleryActiveIndex.value).toBe(-1)
|
||||
})
|
||||
|
||||
it('activates the index that matches the viewed preview URL', () => {
|
||||
it('activates the index that matches the viewed preview URL', async () => {
|
||||
const previewable = [
|
||||
createPreview('p-1'),
|
||||
createPreview('p-2'),
|
||||
createPreview('p-3')
|
||||
createResultItem('p-1'),
|
||||
createResultItem('p-2'),
|
||||
createResultItem('p-3')
|
||||
]
|
||||
const tasks = previewable.map((preview) => createTask(preview))
|
||||
|
||||
@@ -67,37 +106,66 @@ describe('useResultGallery', () => {
|
||||
() => tasks
|
||||
)
|
||||
|
||||
onViewItem(createJobItem('job-2', createPreview('p-2')))
|
||||
await onViewItem(createJobViewItem('job-2', tasks[1]))
|
||||
|
||||
expect(galleryItems.value).toEqual(previewable)
|
||||
expect(galleryActiveIndex.value).toBe(1)
|
||||
expect(galleryItems.value).toEqual([previewable[1]])
|
||||
expect(galleryActiveIndex.value).toBe(0)
|
||||
})
|
||||
|
||||
it('defaults to the first entry when the clicked job lacks a preview', () => {
|
||||
const previewable = [createPreview('p-1'), createPreview('p-2')]
|
||||
it('defaults to the first entry when the clicked job lacks a preview', async () => {
|
||||
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
|
||||
const tasks = previewable.map((preview) => createTask(preview))
|
||||
|
||||
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
||||
() => tasks
|
||||
)
|
||||
|
||||
onViewItem(createJobItem('job-no-preview'))
|
||||
await onViewItem(createJobViewItem('job-no-preview'))
|
||||
|
||||
expect(galleryItems.value).toEqual(previewable)
|
||||
expect(galleryActiveIndex.value).toBe(0)
|
||||
})
|
||||
|
||||
it('defaults to the first entry when no gallery item matches the preview URL', () => {
|
||||
const previewable = [createPreview('p-1'), createPreview('p-2')]
|
||||
it('defaults to the first entry when no gallery item matches the preview URL', async () => {
|
||||
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
|
||||
const tasks = previewable.map((preview) => createTask(preview))
|
||||
|
||||
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
||||
() => tasks
|
||||
)
|
||||
|
||||
onViewItem(createJobItem('job-mismatch', createPreview('missing')))
|
||||
const taskWithMismatchedPreview = createTask(createResultItem('missing'))
|
||||
await onViewItem(
|
||||
createJobViewItem('job-mismatch', taskWithMismatchedPreview)
|
||||
)
|
||||
|
||||
expect(galleryItems.value).toEqual(previewable)
|
||||
expect(galleryItems.value).toEqual([createResultItem('missing')])
|
||||
expect(galleryActiveIndex.value).toBe(0)
|
||||
})
|
||||
|
||||
it('loads full outputs when task has only preview outputs', async () => {
|
||||
const previewOutput = createResultItem('preview-1')
|
||||
const fullOutputs = [
|
||||
createResultItem('full-1'),
|
||||
createResultItem('full-2'),
|
||||
createResultItem('full-3')
|
||||
]
|
||||
|
||||
// Create a task with outputsCount > 1 to trigger lazy loading
|
||||
const job = createMockJob('task-1', 3)
|
||||
const task = new TaskItemImpl(job, {}, [previewOutput])
|
||||
|
||||
// Mock loadFullOutputs to return full outputs
|
||||
const loadedTask = new TaskItemImpl(job, {}, fullOutputs)
|
||||
task.loadFullOutputs = async () => loadedTask
|
||||
|
||||
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
||||
() => [task]
|
||||
)
|
||||
|
||||
await onViewItem(createJobViewItem('job-1', task))
|
||||
|
||||
expect(galleryItems.value).toEqual(fullOutputs)
|
||||
expect(galleryActiveIndex.value).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,39 +1,42 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
/** Minimal preview item interface for gallery filtering. */
|
||||
interface PreviewItem {
|
||||
url: string
|
||||
supportsPreview: boolean
|
||||
}
|
||||
|
||||
/** Minimal task interface for gallery preview. */
|
||||
interface TaskWithPreview<T extends PreviewItem = PreviewItem> {
|
||||
previewOutput?: T
|
||||
}
|
||||
import { findActiveIndex, getOutputsForTask } from '@/services/jobOutputCache'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
/**
|
||||
* Manages result gallery state and activation for queue items.
|
||||
*/
|
||||
export function useResultGallery<T extends PreviewItem>(
|
||||
getFilteredTasks: () => TaskWithPreview<T>[]
|
||||
) {
|
||||
export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
|
||||
const galleryActiveIndex = ref(-1)
|
||||
const galleryItems = shallowRef<T[]>([])
|
||||
const galleryItems = shallowRef<ResultItemImpl[]>([])
|
||||
|
||||
const onViewItem = (item: JobListItem) => {
|
||||
const items: T[] = getFilteredTasks().flatMap((t) => {
|
||||
const preview = t.previewOutput
|
||||
return preview && preview.supportsPreview ? [preview] : []
|
||||
})
|
||||
async function onViewItem(item: JobListItem) {
|
||||
const tasks = getFilteredTasks()
|
||||
if (!tasks.length) return
|
||||
|
||||
const targetTask = item.taskRef
|
||||
const targetOutputs = targetTask
|
||||
? await getOutputsForTask(targetTask)
|
||||
: null
|
||||
|
||||
// Request was superseded by a newer one
|
||||
if (targetOutputs === null && targetTask) return
|
||||
|
||||
// Use target's outputs if available, otherwise fall back to all previews
|
||||
const items = targetOutputs?.length
|
||||
? targetOutputs
|
||||
: tasks
|
||||
.map((t) => t.previewOutput)
|
||||
.filter((o): o is ResultItemImpl => !!o)
|
||||
|
||||
if (!items.length) return
|
||||
|
||||
galleryItems.value = items
|
||||
const activeUrl: string | undefined = item.taskRef?.previewOutput?.url
|
||||
const idx = activeUrl ? items.findIndex((o) => o.url === activeUrl) : 0
|
||||
galleryActiveIndex.value = idx >= 0 ? idx : 0
|
||||
galleryActiveIndex.value = findActiveIndex(
|
||||
items,
|
||||
item.taskRef?.previewOutput?.url
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user