mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-07 00:20:07 +00:00
lazy fetch exec error for dialog
This commit is contained in:
@@ -107,6 +107,7 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
@@ -354,6 +355,7 @@ const { errorMessageValue, copyErrorMessage, reportJobError } =
|
||||
useJobErrorReporting({
|
||||
taskForJob,
|
||||
copyToClipboard,
|
||||
dialog
|
||||
dialog,
|
||||
fetchApi: (url) => api.fetchApi(url)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { computed } from 'vue'
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
import { fetchJobDetail } from '@/platform/remote/comfyui/jobs'
|
||||
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
type CopyHandler = (value: string) => void | Promise<void>
|
||||
type FetchApi = (url: string) => Promise<Response>
|
||||
|
||||
export type JobErrorDialogService = {
|
||||
showErrorDialog: (
|
||||
@@ -13,18 +16,22 @@ export type JobErrorDialogService = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
) => void
|
||||
showExecutionErrorDialog?: (executionError: ExecutionErrorWsMessage) => void
|
||||
}
|
||||
|
||||
type UseJobErrorReportingOptions = {
|
||||
taskForJob: ComputedRef<TaskItemImpl | null>
|
||||
copyToClipboard: CopyHandler
|
||||
dialog: JobErrorDialogService
|
||||
/** Optional fetch function to enable rich error dialogs with traceback */
|
||||
fetchApi?: FetchApi
|
||||
}
|
||||
|
||||
export const useJobErrorReporting = ({
|
||||
taskForJob,
|
||||
copyToClipboard,
|
||||
dialog
|
||||
dialog,
|
||||
fetchApi
|
||||
}: UseJobErrorReportingOptions) => {
|
||||
const errorMessageValue = computed(() => taskForJob.value?.errorMessage ?? '')
|
||||
|
||||
@@ -34,7 +41,26 @@ export const useJobErrorReporting = ({
|
||||
}
|
||||
}
|
||||
|
||||
const reportJobError = () => {
|
||||
const reportJobError = async () => {
|
||||
const task = taskForJob.value
|
||||
if (!task) return
|
||||
|
||||
// Try to fetch rich error details if fetchApi is provided
|
||||
if (fetchApi && dialog.showExecutionErrorDialog) {
|
||||
const jobDetail = await fetchJobDetail(fetchApi, task.promptId)
|
||||
const executionError = jobDetail?.execution_error
|
||||
|
||||
if (executionError) {
|
||||
dialog.showExecutionErrorDialog({
|
||||
prompt_id: task.promptId,
|
||||
timestamp: jobDetail?.create_time ?? Date.now(),
|
||||
...executionError
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to simple error dialog
|
||||
if (errorMessageValue.value) {
|
||||
dialog.showErrorDialog(new Error(errorMessageValue.value), {
|
||||
reportType: 'queueJobError'
|
||||
|
||||
@@ -93,9 +93,26 @@ export function useJobMenu(
|
||||
if (message) await copyToClipboard(message)
|
||||
}
|
||||
|
||||
const reportError = () => {
|
||||
const reportError = async () => {
|
||||
const item = currentMenuItem()
|
||||
const message = item?.taskRef?.errorMessage
|
||||
if (!item) return
|
||||
|
||||
// Try to fetch rich error details from job detail
|
||||
const jobDetail = await fetchJobDetail((url) => api.fetchApi(url), item.id)
|
||||
const executionError = jobDetail?.execution_error
|
||||
|
||||
if (executionError) {
|
||||
// Use rich error dialog with traceback, node info, etc.
|
||||
useDialogService().showExecutionErrorDialog({
|
||||
prompt_id: item.id,
|
||||
timestamp: jobDetail?.create_time ?? Date.now(),
|
||||
...executionError
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to simple error dialog
|
||||
const message = item.taskRef?.errorMessage
|
||||
if (message) {
|
||||
useDialogService().showErrorDialog(new Error(message), {
|
||||
reportType: 'queueJobError'
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { resultItemType, zTaskOutput } from '@/schemas/apiSchema'
|
||||
|
||||
// ============================================================================
|
||||
@@ -68,27 +67,19 @@ const zExtraData = z
|
||||
.passthrough()
|
||||
|
||||
/**
|
||||
* Execution status information
|
||||
* Execution error details for failed jobs.
|
||||
* Contains the same structure as ExecutionErrorWsMessage from WebSocket.
|
||||
*/
|
||||
const zExecutionStatus = z
|
||||
.object({
|
||||
completed: z.boolean(),
|
||||
messages: z.array(z.tuple([z.string(), z.unknown()])),
|
||||
status_str: z.string()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
/**
|
||||
* Execution metadata for a node
|
||||
*/
|
||||
const zExecutionNodeMeta = z
|
||||
.object({
|
||||
node_id: zNodeId,
|
||||
display_node: zNodeId,
|
||||
parent_node: zNodeId.nullable(),
|
||||
real_node_id: zNodeId
|
||||
})
|
||||
.passthrough()
|
||||
const zExecutionError = z.object({
|
||||
node_id: z.string(),
|
||||
node_type: z.string(),
|
||||
executed: z.array(z.string()),
|
||||
exception_message: z.string(),
|
||||
exception_type: z.string(),
|
||||
traceback: z.array(z.string()),
|
||||
current_inputs: z.unknown(),
|
||||
current_outputs: z.unknown()
|
||||
})
|
||||
|
||||
/**
|
||||
* Job detail - returned by GET /api/jobs/{job_id} (detail endpoint)
|
||||
@@ -101,8 +92,9 @@ export const zJobDetail = zRawJobListItem
|
||||
extra_data: zExtraData.optional(),
|
||||
prompt: z.record(z.string(), z.unknown()).optional(),
|
||||
outputs: zTaskOutput.optional(),
|
||||
execution_status: zExecutionStatus.optional(),
|
||||
execution_meta: z.record(z.string(), zExecutionNodeMeta).optional()
|
||||
execution_time: z.number().optional(),
|
||||
workflow_id: z.string().nullable().optional(),
|
||||
execution_error: zExecutionError.nullable().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
@@ -124,4 +116,3 @@ export type JobStatus = z.infer<typeof zJobStatus>
|
||||
export type RawJobListItem = z.infer<typeof zRawJobListItem>
|
||||
export type JobListItem = z.infer<typeof zJobListItem>
|
||||
export type JobDetail = z.infer<typeof zJobDetail>
|
||||
export type ExecutionStatus = z.infer<typeof zExecutionStatus>
|
||||
|
||||
@@ -6,23 +6,34 @@ import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobErrorDialogService } from '@/components/queue/job/useJobErrorReporting'
|
||||
import { useJobErrorReporting } from '@/components/queue/job/useJobErrorReporting'
|
||||
|
||||
const createTaskWithError = (errorMessage?: string): TaskItemImpl =>
|
||||
({ errorMessage }) as unknown as TaskItemImpl
|
||||
const fetchJobDetailMock = vi.fn()
|
||||
vi.mock('@/platform/remote/comfyui/jobs', () => ({
|
||||
fetchJobDetail: (...args: unknown[]) => fetchJobDetailMock(...args)
|
||||
}))
|
||||
|
||||
const createTaskWithError = (
|
||||
promptId: string,
|
||||
errorMessage?: string
|
||||
): TaskItemImpl => ({ promptId, errorMessage }) as unknown as TaskItemImpl
|
||||
|
||||
describe('useJobErrorReporting', () => {
|
||||
let taskState = ref<TaskItemImpl | null>(null)
|
||||
let taskForJob: ComputedRef<TaskItemImpl | null>
|
||||
let copyToClipboard: ReturnType<typeof vi.fn>
|
||||
let showErrorDialog: ReturnType<typeof vi.fn>
|
||||
let showExecutionErrorDialog: ReturnType<typeof vi.fn>
|
||||
let dialog: JobErrorDialogService
|
||||
let composable: ReturnType<typeof useJobErrorReporting>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
taskState = ref<TaskItemImpl | null>(null)
|
||||
taskForJob = computed(() => taskState.value)
|
||||
copyToClipboard = vi.fn()
|
||||
showErrorDialog = vi.fn()
|
||||
dialog = { showErrorDialog }
|
||||
showExecutionErrorDialog = vi.fn()
|
||||
dialog = { showErrorDialog, showExecutionErrorDialog }
|
||||
fetchJobDetailMock.mockResolvedValue(undefined)
|
||||
composable = useJobErrorReporting({
|
||||
taskForJob,
|
||||
copyToClipboard,
|
||||
@@ -35,34 +46,35 @@ describe('useJobErrorReporting', () => {
|
||||
})
|
||||
|
||||
it('exposes a computed message that reflects the current task error', () => {
|
||||
taskState.value = createTaskWithError('First failure')
|
||||
taskState.value = createTaskWithError('job-1', 'First failure')
|
||||
expect(composable.errorMessageValue.value).toBe('First failure')
|
||||
|
||||
taskState.value = createTaskWithError('Second failure')
|
||||
taskState.value = createTaskWithError('job-2', 'Second failure')
|
||||
expect(composable.errorMessageValue.value).toBe('Second failure')
|
||||
})
|
||||
|
||||
it('returns empty string when no error message', () => {
|
||||
taskState.value = createTaskWithError()
|
||||
taskState.value = createTaskWithError('job-1')
|
||||
expect(composable.errorMessageValue.value).toBe('')
|
||||
})
|
||||
|
||||
it('only calls the copy handler when a message exists', () => {
|
||||
taskState.value = createTaskWithError('Clipboard failure')
|
||||
taskState.value = createTaskWithError('job-1', 'Clipboard failure')
|
||||
composable.copyErrorMessage()
|
||||
expect(copyToClipboard).toHaveBeenCalledTimes(1)
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('Clipboard failure')
|
||||
|
||||
copyToClipboard.mockClear()
|
||||
taskState.value = createTaskWithError()
|
||||
taskState.value = createTaskWithError('job-2')
|
||||
composable.copyErrorMessage()
|
||||
expect(copyToClipboard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows error dialog with the error message', () => {
|
||||
taskState.value = createTaskWithError('Queue job error')
|
||||
composable.reportJobError()
|
||||
it('shows simple error dialog when no fetchApi provided', async () => {
|
||||
taskState.value = createTaskWithError('job-1', 'Queue job error')
|
||||
await composable.reportJobError()
|
||||
|
||||
expect(fetchJobDetailMock).not.toHaveBeenCalled()
|
||||
expect(showErrorDialog).toHaveBeenCalledTimes(1)
|
||||
const [errorArg, optionsArg] = showErrorDialog.mock.calls[0]
|
||||
expect(errorArg).toBeInstanceOf(Error)
|
||||
@@ -70,9 +82,95 @@ describe('useJobErrorReporting', () => {
|
||||
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
|
||||
})
|
||||
|
||||
it('does nothing when no error message exists', () => {
|
||||
taskState.value = createTaskWithError()
|
||||
composable.reportJobError()
|
||||
it('does nothing when no task exists', async () => {
|
||||
taskState.value = null
|
||||
await composable.reportJobError()
|
||||
expect(showErrorDialog).not.toHaveBeenCalled()
|
||||
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('with fetchApi provided', () => {
|
||||
let fetchApi: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchApi = vi.fn()
|
||||
composable = useJobErrorReporting({
|
||||
taskForJob,
|
||||
copyToClipboard,
|
||||
dialog,
|
||||
fetchApi
|
||||
})
|
||||
})
|
||||
|
||||
it('shows rich error dialog when execution_error available', async () => {
|
||||
const executionError = {
|
||||
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: {}
|
||||
}
|
||||
fetchJobDetailMock.mockResolvedValue({
|
||||
id: 'job-1',
|
||||
create_time: 12345,
|
||||
execution_error: executionError
|
||||
})
|
||||
taskState.value = createTaskWithError('job-1', 'CUDA out of memory')
|
||||
|
||||
await composable.reportJobError()
|
||||
|
||||
expect(fetchJobDetailMock).toHaveBeenCalledWith(fetchApi, 'job-1')
|
||||
expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1)
|
||||
expect(showExecutionErrorDialog).toHaveBeenCalledWith({
|
||||
prompt_id: 'job-1',
|
||||
timestamp: 12345,
|
||||
...executionError
|
||||
})
|
||||
expect(showErrorDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to simple error dialog when no execution_error', async () => {
|
||||
fetchJobDetailMock.mockResolvedValue({
|
||||
id: 'job-1',
|
||||
execution_error: null
|
||||
})
|
||||
taskState.value = createTaskWithError('job-1', 'Job failed')
|
||||
|
||||
await composable.reportJobError()
|
||||
|
||||
expect(fetchJobDetailMock).toHaveBeenCalledWith(fetchApi, 'job-1')
|
||||
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
expect(showErrorDialog).toHaveBeenCalledTimes(1)
|
||||
const [errorArg, optionsArg] = showErrorDialog.mock.calls[0]
|
||||
expect(errorArg).toBeInstanceOf(Error)
|
||||
expect(errorArg.message).toBe('Job failed')
|
||||
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
|
||||
})
|
||||
|
||||
it('falls back to simple error dialog when fetch fails', async () => {
|
||||
fetchJobDetailMock.mockResolvedValue(undefined)
|
||||
taskState.value = createTaskWithError('job-1', 'Job failed')
|
||||
|
||||
await composable.reportJobError()
|
||||
|
||||
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
expect(showErrorDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does nothing when no error message and no execution_error', async () => {
|
||||
fetchJobDetailMock.mockResolvedValue({
|
||||
id: 'job-1',
|
||||
execution_error: null
|
||||
})
|
||||
taskState.value = createTaskWithError('job-1')
|
||||
|
||||
await composable.reportJobError()
|
||||
|
||||
expect(showErrorDialog).not.toHaveBeenCalled()
|
||||
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,6 +83,7 @@ vi.mock('@/scripts/utils', () => ({
|
||||
|
||||
const dialogServiceMock = {
|
||||
showErrorDialog: vi.fn(),
|
||||
showExecutionErrorDialog: vi.fn(),
|
||||
prompt: vi.fn()
|
||||
}
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
@@ -287,7 +288,52 @@ describe('useJobMenu', () => {
|
||||
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 = {
|
||||
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: {}
|
||||
}
|
||||
fetchJobDetailMock.mockResolvedValue({
|
||||
id: 'job-1',
|
||||
create_time: 12345,
|
||||
execution_error: executionError
|
||||
})
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { errorMessage: 'CUDA out of memory' } as any
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(fetchJobDetailMock).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
'job-1'
|
||||
)
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledTimes(1)
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledWith({
|
||||
prompt_id: 'job-1',
|
||||
timestamp: 12345,
|
||||
...executionError
|
||||
})
|
||||
expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to simple error dialog when no execution_error', async () => {
|
||||
fetchJobDetailMock.mockResolvedValue({
|
||||
id: 'job-1',
|
||||
execution_error: null
|
||||
})
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
@@ -298,8 +344,9 @@ describe('useJobMenu', () => {
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
entry?.onClick?.()
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
expect(dialogServiceMock.showErrorDialog).toHaveBeenCalledTimes(1)
|
||||
const [errorArg, optionsArg] =
|
||||
dialogServiceMock.showErrorDialog.mock.calls[0]
|
||||
@@ -309,6 +356,7 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('ignores error actions when message missing', async () => {
|
||||
fetchJobDetailMock.mockResolvedValue({ id: 'job-1', execution_error: null })
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
@@ -325,6 +373,7 @@ describe('useJobMenu', () => {
|
||||
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled()
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const previewCases = [
|
||||
|
||||
Reference in New Issue
Block a user