Files
ComfyUI_frontend/src/components/rightSidePanel/errors/ErrorNodeCard.test.ts
jaeone94 74b9f16b62 Refine execution error presentation (#12683)
## Summary

This is the first PR in a planned stack to modernize the Workflow
Overview error tab. It focuses only on execution-style errors:
validation errors, runtime errors, and known prompt errors.

The intent is to establish the catalog-driven presentation model before
touching the larger missing-resource cards. Validation and prompt errors
are known product states, so this PR makes them read more like
structured guidance instead of generic reportable failures. Runtime
errors remain reportable, but their details are reorganized so the error
log is easier to scan and copy.

## What changed

- Groups validation errors by error catalog id instead of node
class/type.
- Adds an `unknown_validation_error` fallback catalog id so validation
grouping can follow one rule without special-case missing catalog ids.
- Shows validation group title and message once, then lists each
affected input as a compact item row.
- Adds per-item validation detail disclosure so detailed validation text
is still available without repeating the group title/message for every
item.
- Keeps locate-node behavior available from validation rows, including
keyboard/ARIA disclosure wiring.
- Removes GitHub, copy, and help actions from validation/prompt errors
because these are known, cataloged errors where the UI copy should guide
the user directly.
- Refines runtime error cards so the error log is visible by default,
has its own header, and keeps copy/report actions inside the log area.
- Removes the special full-panel singleton runtime layout so runtime
errors keep the same fixed card rhythm as the other error groups.
- Keeps runtime errors reportable via Get Help and Find on GitHub,
because these can still represent unexpected execution failures.
- Updates prompt error detail styling to match the darker runtime
error-log treatment.
- Restores display-message semantics for grouped execution messages:
`displayMessage ?? message` is used for user-facing dedupe instead of
raw backend-only messages.
- Adds focused unit coverage for catalog grouping, fallback validation
catalog ids, display-message grouping, runtime detail behavior, and the
updated prompt/validation action model.

## Planned stack

This PR intentionally keeps the first slice narrow. The broader redesign
is planned as a sequence of follow-up PRs rather than one large change:

1. Execution errors, this PR: validation, runtime, and prompt error
grouping/presentation.
2. Missing media: simplify image/video/audio missing-media cards around
catalog item labels and locate actions.
3. Missing node and swap node: align missing-pack rows, nested node
references, install/replace actions, and locate behavior.
4. Missing model: unify OSS and Cloud presentation, simplify
download/import actions, and improve import/download progress behavior.

The goal is to review and stabilize each slice before stacking the next
one. This is especially important because later missing-model changes
are much larger and should not obscure the catalog/error-card behavior
introduced here.

## Review focus

- Validation errors should now group by catalog id, not by node class.
- Validation groups intentionally show one message per group, with
individual affected inputs rendered as rows.
- Prompt and validation errors intentionally no longer show
report/copy/help actions.
- Runtime errors intentionally still show report actions, but only
inside the error-log panel.
- Node id badges are intentionally not shown in these execution error
rows; the follow-up missing-resource PRs will handle their own row
treatments separately.
- This PR does not change missing media, missing model, missing node
pack, or swap node cards.

## Screenshots  
### This PR 

Validation error
<img width="457" height="362" alt="스크린샷 2026-06-07 오전 4 26 19"
src="https://github.com/user-attachments/assets/4c35b9f3-57dd-4dae-b44a-6d2fd8547b7c"
/>

Runtime error
<img width="454" height="545" alt="스크린샷 2026-06-07 오전 4 24 24"
src="https://github.com/user-attachments/assets/b7d4482f-b35b-4ed2-90f2-0a62dafa3519"
/>

Prompt / Service error 
<img width="456" height="192" alt="스크린샷 2026-06-07 오전 4 27 58"
src="https://github.com/user-attachments/assets/aeec0978-b47f-40c7-ab71-0a0d18ceb054"
/>


### Old (main)
Validation error
<img width="457" height="853" alt="스크린샷 2026-06-07 오전 4 25 09"
src="https://github.com/user-attachments/assets/185dd573-430d-4041-8b31-a8eb6346f1ff"
/>

Runtime error
<img width="455" height="554" alt="스크린샷 2026-06-07 오전 4 24 58"
src="https://github.com/user-attachments/assets/deb1c09d-ea58-4d6a-9ac6-d2a3a9832fbe"
/>

Prompt / Service error
<img width="455" height="297" alt="스크린샷 2026-06-07 오전 4 28 14"
src="https://github.com/user-attachments/assets/c68eef7c-6525-4a5b-858c-6482fe76ad27"
/>





## Validation

- `pnpm format:check`
- `pnpm test:unit src/components/rightSidePanel/errors/TabErrors.test.ts
src/components/rightSidePanel/errors/ErrorNodeCard.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts
src/platform/errorCatalog/errorMessageResolver.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm knip`
- `pnpm build`
2026-06-08 06:52:57 +00:00

425 lines
12 KiB
TypeScript

import { createTestingPinia } from '@pinia/testing'
import { render, screen, waitFor } 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 ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
const mockSerialize = vi.fn(() => ({ nodes: [] }))
const mockGenerateErrorReport = vi.fn(
(_data?: unknown) => '# ComfyUI Error Report\n...'
)
vi.mock('@/scripts/api', () => ({
api: {
getLogs: () => mockGetLogs()
}
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: () => mockSerialize()
}
}
}))
vi.mock('@/utils/errorReportUtil', () => ({
generateErrorReport: (data: unknown) => mockGenerateErrorReport(data)
}))
const mockTrackHelpResourceClicked = vi.fn()
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackUiButtonClicked: vi.fn(),
trackHelpResourceClicked: mockTrackHelpResourceClicked
}))
}))
const mockExecuteCommand = vi.fn()
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn(() => ({
execute: mockExecuteCommand
}))
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
staticUrls: {
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues'
}
}))
}))
describe('ErrorNodeCard.vue', () => {
let i18n: ReturnType<typeof createI18n>
beforeEach(() => {
vi.clearAllMocks()
cardIdCounter = 0
mockGetLogs.mockResolvedValue('mock server logs')
mockGenerateErrorReport.mockReturnValue('# ComfyUI Error Report\n...')
i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
copy: 'Copy',
details: 'Details',
findIssues: 'Find Issues',
findOnGithub: 'Find on GitHub',
getHelpAction: 'Get Help'
},
rightSidePanel: {
locateNode: 'Locate Node',
enterSubgraph: 'Enter Subgraph',
errorLog: 'Error log',
findOnGithubTooltip: 'Search GitHub issues for related problems',
getHelpTooltip:
'Report this error and we\u0027ll help you resolve it'
},
issueReport: {
helpFix: 'Help Fix This'
}
}
}
})
})
function renderCard(
card: ErrorCardData,
options: { initialState?: Record<string, unknown> } = {}
) {
const user = userEvent.setup()
const onCopyToClipboard = vi.fn()
const onLocateNode = vi.fn()
render(ErrorNodeCard, {
props: { card, onCopyToClipboard, onLocateNode },
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState: options.initialState ?? {
systemStats: {
systemStats: {
system: {
os: 'Linux',
python_version: '3.11.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.1.0',
argv: ['--listen']
},
devices: [
{
name: 'NVIDIA RTX 4090',
type: 'cuda',
vram_total: 24000,
vram_free: 12000,
torch_vram_total: 24000,
torch_vram_free: 12000
}
]
}
}
}
})
],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
Button: {
template: '<button v-bind="$attrs"><slot /></button>'
}
}
}
})
return { user, onCopyToClipboard, onLocateNode }
}
async function toggleRuntimeDetails(
user: ReturnType<typeof userEvent.setup>
) {
await user.click(screen.getByRole('button', { name: /Details/ }))
}
let cardIdCounter = 0
function makeRuntimeErrorCard(): ErrorCardData {
return {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeTitle: 'KSampler',
errors: [
{
message: 'RuntimeError: CUDA out of memory',
details: 'Traceback line 1\nTraceback line 2',
isRuntimeError: true,
exceptionType: 'RuntimeError'
}
]
}
}
function makePromptErrorCard(): ErrorCardData {
return {
id: '__prompt__',
title: 'Prompt has no outputs',
errors: [
{
message: 'Server Error: No outputs',
details: 'Error details',
displayMessage:
'The workflow does not contain any output nodes to produce a result.'
}
]
}
}
it('shows runtime details by default and can collapse them', async () => {
const reportText =
'# ComfyUI Error Report\n## System Information\n- OS: Linux'
mockGenerateErrorReport.mockReturnValue(reportText)
const { user } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(screen.queryByRole('listitem')).not.toBeInTheDocument()
expect(screen.getByText('Error log')).toBeInTheDocument()
const detailsButton = screen.getByRole('button', { name: /Details/ })
const detailsRegion = screen.getByRole('region', { name: 'Error log' })
expect(detailsButton).toHaveAttribute(
'aria-controls',
detailsRegion.getAttribute('id')
)
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
expect(screen.getByText(/System Information/)).toBeInTheDocument()
expect(screen.getByText(/OS: Linux/)).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Find on GitHub/ })
).toBeInTheDocument()
await toggleRuntimeDetails(user)
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Find on GitHub/ })
).not.toBeInTheDocument()
})
it('locates the node when the runtime node title is clicked', async () => {
const { user, onLocateNode } = renderCard(makeRuntimeErrorCard())
await user.click(screen.getByRole('button', { name: 'KSampler' }))
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not generate report for non-runtime errors', async () => {
renderCard(makePromptErrorCard())
await waitFor(() => {
expect(screen.getByText('Error details')).toBeInTheDocument()
})
expect(mockGetLogs).not.toHaveBeenCalled()
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
})
it('displays original details for non-runtime errors', async () => {
renderCard(makePromptErrorCard())
await waitFor(() => {
expect(screen.getByText('Error details')).toBeInTheDocument()
})
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
})
it('hides grouped catalog copy and shows the item label as a list item', async () => {
renderCard({
id: `node-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeTitle: 'KSampler',
errors: [
{
message: 'Required input is missing',
details: 'model',
displayTitle: 'Missing connection',
displayMessage:
'Required input slots have no connection feeding them.',
displayDetails: 'KSampler is missing a required input: model',
displayItemLabel: 'KSampler - model'
}
]
})
await waitFor(() => {
expect(screen.getByText('KSampler - model')).toBeInTheDocument()
})
expect(screen.getByRole('listitem')).toHaveTextContent('KSampler - model')
expect(screen.queryByText('Missing connection')).not.toBeInTheDocument()
expect(
screen.queryByText(
'Required input slots have no connection feeding them.'
)
).not.toBeInTheDocument()
expect(
screen.queryByText('KSampler is missing a required input: model')
).not.toBeInTheDocument()
})
it('copies enriched report when copy button is clicked for runtime error', async () => {
const reportText = '# Full Report Content'
mockGenerateErrorReport.mockReturnValue(reportText)
const { user, onCopyToClipboard } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /Copy/ }))
expect(onCopyToClipboard).toHaveBeenCalledTimes(1)
expect(onCopyToClipboard.mock.calls[0][0]).toContain(
'# Full Report Content'
)
})
it('generates report with fallback logs when getLogs fails', async () => {
mockGetLogs.mockRejectedValue(new Error('Network error'))
renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
serverLogs: 'Failed to retrieve server logs'
})
)
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
})
it('falls back to original details when generateErrorReport throws', async () => {
mockGenerateErrorReport.mockImplementation(() => {
throw new Error('Serialization error')
})
renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
})
it('opens GitHub issues search when Find Issue button is clicked', async () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const { user } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('github.com/Comfy-Org/ComfyUI/issues?q='),
'_blank',
'noopener,noreferrer'
)
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('CUDA%20out%20of%20memory'),
expect.any(String),
expect.any(String)
)
openSpy.mockRestore()
})
it('executes ContactSupport command when Get Help button is clicked', async () => {
const { user } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
await user.click(screen.getByRole('button', { name: /Get Help/ }))
expect(mockExecuteCommand).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
expect.objectContaining({
resource_type: 'help_feedback',
source: 'error_dialog'
})
)
})
it('passes exceptionType from error item to report generator', async () => {
renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
exceptionType: 'RuntimeError'
})
)
})
it('uses fallback exception type when error item has no exceptionType', async () => {
const card: ErrorCardData = {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeTitle: 'KSampler',
errors: [
{
message: 'Unknown error occurred',
details: 'Some traceback',
isRuntimeError: true
}
]
}
renderCard(card)
await waitFor(() => {
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
exceptionType: 'Runtime Error'
})
)
})
it('falls back to original details when systemStats is unavailable', async () => {
renderCard(makeRuntimeErrorCard(), {
initialState: {
systemStats: { systemStats: null }
}
})
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
})
})