mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
## Summary This PR introduces the frontend error catalog display resolver as the foundation for the DES-220 / FE-816 error messaging work. The main goal is to create a single FE boundary where raw Core/Cloud error payloads can be converted into human-friendly display fields, while preserving the original API contract fields (`message` and `details`) unchanged. UI components can now prefer resolved display copy when it exists and fall back to the raw API copy otherwise. As a small concrete sample, this PR implements the first cataloged validation error: - `required_input_missing` is resolved as the `missing_connection` catalog item. - Panel title: `Missing connection` - Panel message: `Required input slots have no connection feeding them.` - Detail/item copy can include the node and input name, e.g. `KSampler is missing a required input: model` and `KSampler - model`. - Single-error toast/overlay-oriented fields are added to the data model for follow-up UI work, but this PR does not redesign the overlay. ## What This PR Targets This PR is intentionally scoped as the skeleton PR for the error catalog UX system. It adds: - A new resolver module under `src/platform/errorCatalog`. - Shared resolved display fields: - `catalogId` - `displayTitle` - `displayMessage` - `displayDetails` - `displayItemLabel` - `toastTitle` - `toastMessage` - A resolver entry point for run-time workflow errors: - node validation errors - execution/runtime errors - prompt errors - A resolver entry point for pre-run missing-resource groups: - missing node packs - swap nodes - missing models - missing media - Error group wiring so `useErrorGroups` resolves display copy in one place instead of making UI components own message decisions. - The first real validation rule for `required_input_missing` / Missing connection. - The existing prompt error copy moved into the `errorCatalog.promptErrors` namespace in `src/locales/en/main.json`. - Tests covering the resolver, grouping behavior, panel rendering, prompt error copy, missing group copy, and fallback behavior. ## What This PR Deliberately Does Not Target This PR avoids the larger UX and product behavior changes so the foundation can land separately. It does not: - Redesign the error overlay. - Redesign the right-side error panel. - Change the shape of Core/Cloud API error payloads. - Replace raw `message` / `details`; those remain intact for API-contract alignment and technical debugging. - Re-group execution errors by final message type yet. - Add special runtime error messaging for credits, timeouts, content policy, OOM, or rate limits. - Render the new `displayItemLabel` everywhere it will eventually be useful. ## User-Facing Behavior Most behavior is preserved. The main visible change is for missing required input validation errors. Those now display as Missing connection copy instead of exposing the raw validation message directly. Prompt errors should keep the same user-facing wording as before, but the source of that wording now lives under the error catalog namespace. Missing node/model/media/swap-node groups still preserve the existing titles, counts, and friendly messages, but their display copy now flows through the same resolver boundary. Execution/runtime errors receive catalog fields for future toast/overlay usage, but the current runtime overlay path intentionally keeps the raw technical error copy until the overlay redesign PR decides how to consume the new fields. ## Screenshots Before <img width="505" height="266" alt="스크린샷 2026-05-22 오후 2 15 27" src="https://github.com/user-attachments/assets/09e8eb31-dca4-42d8-8237-9474cb71a14c" /> <img width="463" height="317" alt="스크린샷 2026-05-22 오후 2 16 09" src="https://github.com/user-attachments/assets/c0a0159e-5bd9-4b3f-9c21-c0040373fbca" /> After <img width="482" height="297" alt="스크린샷 2026-05-22 오후 2 14 25" src="https://github.com/user-attachments/assets/4ca10bf0-29d2-4b65-940e-0d78db3fd278" /> <img width="460" height="194" alt="스크린샷 2026-05-22 오후 2 16 55" src="https://github.com/user-attachments/assets/20848054-5012-4dd3-b903-ef8c920f70c8" /> ## Follow-Up PR Plan This PR is the first stacked PR in the error catalog work. Follow-up PRs are expected to build on this foundation in roughly this order: 1. Expand general execution error messaging. - Add broader validation error handling beyond `required_input_missing`, including list/range/value validation cases. - Add general runtime execution messaging. - Continue migrating prompt error display decisions into the catalog resolver. 2. Add special runtime error messaging. - Credits / insufficient credits. - Timeout. - Content not allowed / blocked content. - Server crash. - Out of memory. - Rate limiting. - Other high-volume Cloud-only runtime failures from DES-220. 3. Re-group execution errors by message/catalog type. - Move away from grouping primarily by node class when the cataloged error type is the more useful user-facing grouping key. - Keep raw technical details available inside cards/logs. 4. Update the error overlay behavior. - Use `toastTitle` and `toastMessage` for single-error cases. - Use aggregate copy such as "N errors found" for multi-error cases. - Add node navigation affordances where appropriate. 5. Update the right-side error panel design. - Render resolved item labels such as `Node name - Input name`. - Align expanded card details and logs with the new design. - Preserve copy/debug affordances for technical details. 6. Fold in related missing media/model/node messaging improvements. - FE-583 should become a child/follow-up issue in this stack for improving missing image/media messaging. ## Validation - `pnpm format` - `pnpm lint` - `pnpm typecheck` - `pnpm test:unit` - Targeted resolver/grouping tests during review iterations - `pnpm knip` `pnpm knip` passes with only the pre-existing tag hint: `Unused tag in src/scripts/metadata/flac.ts: getFromFlacBuffer → @knipIgnoreUnusedButUsedByCustomNodes`
306 lines
9.2 KiB
TypeScript
306 lines
9.2 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { render, screen } from '@testing-library/vue'
|
|
import userEvent from '@testing-library/user-event'
|
|
import PrimeVue from 'primevue/config'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createI18n } from 'vue-i18n'
|
|
import TabErrors from './TabErrors.vue'
|
|
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
|
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
|
|
|
vi.mock('@/scripts/app', () => ({
|
|
app: {
|
|
rootGraph: {
|
|
serialize: vi.fn(() => ({})),
|
|
getNodeById: vi.fn()
|
|
}
|
|
}
|
|
}))
|
|
|
|
vi.mock('@/utils/graphTraversalUtil', () => ({
|
|
getNodeByExecutionId: vi.fn(),
|
|
getRootParentNode: vi.fn(() => null),
|
|
forEachNode: vi.fn(),
|
|
mapAllNodes: vi.fn(() => [])
|
|
}))
|
|
|
|
vi.mock('@/composables/useCopyToClipboard', () => ({
|
|
useCopyToClipboard: vi.fn(() => ({
|
|
copyToClipboard: vi.fn()
|
|
}))
|
|
}))
|
|
|
|
vi.mock('@/services/litegraphService', () => ({
|
|
useLitegraphService: vi.fn(() => ({
|
|
fitView: vi.fn()
|
|
}))
|
|
}))
|
|
|
|
describe('TabErrors.vue', () => {
|
|
let i18n: ReturnType<typeof createI18n>
|
|
|
|
beforeEach(() => {
|
|
i18n = createI18n({
|
|
legacy: false,
|
|
locale: 'en',
|
|
messages: {
|
|
en: {
|
|
g: {
|
|
workflow: 'Workflow',
|
|
copy: 'Copy'
|
|
},
|
|
rightSidePanel: {
|
|
noErrors: 'No errors',
|
|
noneSearchDesc: 'No results found',
|
|
missingModels: {
|
|
missingModelsTitle: 'Missing Models',
|
|
downloadAll: 'Download all',
|
|
refresh: 'Refresh',
|
|
refreshing: 'Refreshing missing models.'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
function renderComponent(initialState = {}) {
|
|
const user = userEvent.setup()
|
|
render(TabErrors, {
|
|
global: {
|
|
plugins: [
|
|
PrimeVue,
|
|
i18n,
|
|
createTestingPinia({
|
|
createSpy: vi.fn,
|
|
initialState
|
|
})
|
|
],
|
|
stubs: {
|
|
AsyncSearchInput: {
|
|
template:
|
|
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
|
},
|
|
PropertiesAccordionItem: {
|
|
template: '<div><slot name="label" /><slot /></div>'
|
|
},
|
|
Button: {
|
|
template: '<button v-bind="$attrs"><slot /></button>'
|
|
}
|
|
}
|
|
}
|
|
})
|
|
return { user }
|
|
}
|
|
|
|
it('renders "no errors" state when store is empty', () => {
|
|
renderComponent()
|
|
expect(screen.getByText('No errors')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders prompt-level errors with resolved display message', async () => {
|
|
renderComponent({
|
|
executionError: {
|
|
lastPromptError: {
|
|
type: 'prompt_no_outputs',
|
|
message: 'Server Error: No outputs',
|
|
details: 'Error details'
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
|
|
expect(
|
|
screen.getByText(
|
|
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
|
)
|
|
).toBeInTheDocument()
|
|
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('renders node validation errors grouped by class_type', async () => {
|
|
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
|
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
|
title: 'CLIP Text Encode'
|
|
} as ReturnType<typeof getNodeByExecutionId>)
|
|
|
|
renderComponent({
|
|
executionError: {
|
|
lastNodeErrors: {
|
|
'6': {
|
|
class_type: 'CLIPTextEncode',
|
|
errors: [
|
|
{ message: 'Required input is missing', details: 'Input: text' }
|
|
]
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(screen.getByText('CLIPTextEncode')).toBeInTheDocument()
|
|
expect(screen.getByText('#6')).toBeInTheDocument()
|
|
expect(screen.getByText('CLIP Text Encode')).toBeInTheDocument()
|
|
expect(screen.getByText('Required input is missing')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders runtime execution errors from WebSocket', async () => {
|
|
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
|
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
|
title: 'KSampler'
|
|
} as ReturnType<typeof getNodeByExecutionId>)
|
|
|
|
renderComponent({
|
|
executionError: {
|
|
lastExecutionError: {
|
|
prompt_id: 'abc',
|
|
node_id: '10',
|
|
node_type: 'KSampler',
|
|
exception_message: 'Out of memory',
|
|
exception_type: 'RuntimeError',
|
|
traceback: ['Line 1', 'Line 2'],
|
|
timestamp: Date.now()
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
|
expect(screen.getByText('#10')).toBeInTheDocument()
|
|
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
|
|
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('filters errors based on search query', async () => {
|
|
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
|
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
|
|
|
|
const { user } = renderComponent({
|
|
executionError: {
|
|
lastNodeErrors: {
|
|
'1': {
|
|
class_type: 'CLIPTextEncode',
|
|
errors: [{ message: 'Missing text input' }]
|
|
},
|
|
'2': {
|
|
class_type: 'KSampler',
|
|
errors: [{ message: 'Out of memory' }]
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
|
|
1
|
|
)
|
|
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
|
|
|
await user.type(screen.getByRole('textbox'), 'Missing text input')
|
|
|
|
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
|
|
1
|
|
)
|
|
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('calls copyToClipboard when copy button is clicked', async () => {
|
|
const { useCopyToClipboard } =
|
|
await import('@/composables/useCopyToClipboard')
|
|
const mockCopy = vi.fn()
|
|
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
|
|
|
|
const { user } = renderComponent({
|
|
executionError: {
|
|
lastNodeErrors: {
|
|
'1': {
|
|
class_type: 'TestNode',
|
|
errors: [{ message: 'Test message', details: 'Test details' }]
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
await user.click(screen.getByTestId('error-card-copy'))
|
|
|
|
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
|
|
})
|
|
|
|
it('renders single runtime error outside accordion in full-height panel', async () => {
|
|
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
|
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
|
title: 'KSampler'
|
|
} as ReturnType<typeof getNodeByExecutionId>)
|
|
|
|
renderComponent({
|
|
executionError: {
|
|
lastExecutionError: {
|
|
prompt_id: 'abc',
|
|
node_id: '10',
|
|
node_type: 'KSampler',
|
|
exception_message: 'Out of memory',
|
|
exception_type: 'RuntimeError',
|
|
traceback: ['Line 1', 'Line 2'],
|
|
timestamp: Date.now()
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
|
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
|
|
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
|
|
expect(screen.getAllByText('RuntimeError: Out of memory')).toHaveLength(1)
|
|
})
|
|
|
|
it('shows missing model Refresh in the section header when no model is downloadable', async () => {
|
|
const missingModel = {
|
|
nodeId: '1',
|
|
nodeType: 'CheckpointLoaderSimple',
|
|
widgetName: 'ckpt_name',
|
|
name: 'local-only.safetensors',
|
|
directory: 'checkpoints',
|
|
isMissing: true,
|
|
isAssetSupported: true
|
|
} satisfies MissingModelCandidate
|
|
|
|
const { user } = renderComponent({
|
|
missingModel: {
|
|
missingModelCandidates: [missingModel]
|
|
}
|
|
})
|
|
const missingModelStore = useMissingModelStore()
|
|
|
|
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
|
expect(
|
|
screen.queryByTestId('missing-model-actions')
|
|
).not.toBeInTheDocument()
|
|
|
|
await user.click(screen.getByTestId('missing-model-header-refresh'))
|
|
|
|
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
|
|
const missingModel = {
|
|
nodeId: '1',
|
|
nodeType: 'CheckpointLoaderSimple',
|
|
widgetName: 'ckpt_name',
|
|
name: 'downloadable.safetensors',
|
|
url: 'https://huggingface.co/comfy/test/resolve/main/downloadable.safetensors',
|
|
directory: 'checkpoints',
|
|
isMissing: true,
|
|
isAssetSupported: true
|
|
} satisfies MissingModelCandidate
|
|
|
|
renderComponent({
|
|
missingModel: {
|
|
missingModelCandidates: [missingModel]
|
|
}
|
|
})
|
|
|
|
expect(
|
|
screen.queryByTestId('missing-model-header-refresh')
|
|
).not.toBeInTheDocument()
|
|
expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
|
|
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
|
|
})
|
|
})
|