Files
ComfyUI_frontend/src/components/error/useErrorOverlayState.test.ts
jaeone94 6850d22d99 Redesign error overlay count and toast behavior (#12871)
## Summary

Redesign the run error overlay so its count and copy use the same
grouped error semantics as the redesigned Error tab, while keeping
single-error toast copy precise.

This is a stacked follow-up to #12828. The parent PR redesigns the Error
tab cards and centralizes their grouped/row-based presentation. This PR
applies the same mental model to the compact overlay shown from the Run
button path, so users no longer see one count in the Error tab and a
different count in the overlay.

## Changes

- **What**: Align the error overlay count with the Error tab's grouped
error count.
- The overlay now reads the same grouped error surface that powers the
Error tab hero instead of independently summing raw store error counts.
- Validation/runtime/prompt execution groups use their grouped `count`.
- Missing model/media/node/swap groups keep the row/group count
semantics introduced by the Error tab redesign.
- This avoids cases where the overlay headline says one number while the
panel summarizes another.

- **What**: Preserve the existing single-error toast behavior while
adding explicit multi-row handling.
- A single true leaf still uses the catalog/resolver toast title and
toast message.
- A single grouped execution error with multiple node/input items uses
that group's title/message instead of the generic aggregate copy.
- A single missing model/media group with multiple model/file rows uses
the generic aggregate copy because it represents multiple actionable
rows.
- A single missing model/media row referenced by multiple nodes uses the
group title/message, since there is still only one model/file to
resolve.
- Multiple top-level groups continue to use the aggregate "N errors
found" style copy.

- **What**: Restyle the overlay toast to match the new error-surface
direction.
- Adds a compact dark card with a destructive left accent and a visible
outline for better contrast against the workspace.
  - Keeps the close button in the card header area.
- Keeps the primary "View details" action, but adjusts spacing, size,
and typography to better match the Figma direction.
- Removes the older footer-style dismiss action so the overlay behaves
like a focused status toast rather than a secondary dialog.

- **What**: Share error grouping/count helpers instead of duplicating
local logic.
- Extracts execution item-list detection for reuse between the Error tab
render path and count logic.
- Extracts missing-model grouping/count helpers so missing-model row
count semantics have one implementation.
- Removes `groupedErrorMessages`, which became unused after the overlay
copy decision moved to grouped error state.

- **Breaking**: None.

- **Dependencies**: None.

## Review Focus

- **Stack boundary**: Please review this against
`jaeone/fe-816-error-card-redesign`, not against `main` directly. The
parent PR is #12828 and contains the Error tab card redesign that this
overlay work builds on.

- **Count semantics**: The visible overlay count intentionally changes
from raw store counts to grouped Error tab counts. This is not just a
refactor; it is the intended product behavior so the compact overlay and
the panel hero agree.

- **Overlay message branches**: The overlay can now choose between three
message modes. The goal is to keep precise single-error copy where it is
useful, but avoid showing one node-specific toast message when the
overlay actually represents multiple actionable rows.

| Branch | When it applies | Overlay title source | Overlay message
source | Example output |
| --- | --- | --- | --- | --- |
| Aggregate summary | More than one top-level error group, or one
missing model/media group with multiple actionable model/file rows. |
Generic aggregate title using grouped count. | Generic aggregate
message. | Title: `2 errors found`<br>Message: `Resolve them before
running the workflow.`<br>Example case: one Missing Models group
containing `first.safetensors` and `second.safetensors`. |
| Group summary | Exactly one error group that is not a true single
leaf, but should still be described by the group. This includes one
execution catalog group with multiple items, or one missing model/media
row referenced by multiple nodes. | The group's `displayTitle`. | The
group's `displayMessage`. | Title: `Missing connection`<br>Message:
`Required input slots have no connection feeding them.`<br>Example case:
one validation group with `KSampler - model` and `KSampler - positive`
rows. |
| Single leaf toast | Exactly one group, one actionable row/card, and at
most one node reference. | Resolver/catalog `toastTitle`. |
Resolver/catalog `toastMessage`. | Title: `Model missing`<br>Message:
`CheckpointLoaderSimple is missing missing.safetensors.`<br>Example
case: one missing model file referenced by one node. |

- **Scope control**: This PR intentionally does not redesign the full
Error Overlay flow beyond the compact toast/card. It also does not
revisit the deeper Error tab card layouts already handled in #12828.

- **Accessibility**: The toast keeps `role="status"` for polite
announcement semantics. The duplicate `aria-live` attribute was removed
during cleanup because `role="status"` already implies polite
live-region behavior.

## Validation

- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm knip`
- `pnpm test:unit src/components/error/useErrorOverlayState.test.ts
src/platform/missingModel/missingModelGrouping.test.ts
src/components/error/ErrorOverlay.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts`
- Pre-commit staged checks passed.
- Pre-push `knip` passed.

## Screenshots (if applicable)
<img width="454" height="179" alt="스크린샷 2026-06-16 오후 6 00 10"
src="https://github.com/user-attachments/assets/a85376ba-2b22-4cf8-a6fa-79f83fb8b244"
/>
<img width="453" height="179" alt="스크린샷 2026-06-16 오후 6 00 31"
src="https://github.com/user-attachments/assets/d9a1d4bd-92ab-451a-bb79-e7cfbc3af7c6"
/>
<img width="486" height="148" alt="스크린샷 2026-06-16 오후 6 00 55"
src="https://github.com/user-attachments/assets/b66faf96-65c8-4a22-9ff9-e8ccd450986e"
/>
<img width="395" height="127" alt="스크린샷 2026-06-16 오후 6 01 22"
src="https://github.com/user-attachments/assets/c64443f9-0eba-4b2b-8049-c1887c788b1e"
/>
<img width="384" height="134" alt="스크린샷 2026-06-16 오후 6 01 30"
src="https://github.com/user-attachments/assets/42f4fcae-b003-4df9-8f3a-0fda85a90880"
/>
<img width="376" height="129" alt="스크린샷 2026-06-16 오후 6 01 53"
src="https://github.com/user-attachments/assets/ce9030d0-2a98-4b38-9e7d-7a9c3103960f"
/>
<img width="379" height="128" alt="스크린샷 2026-06-16 오후 6 02 01"
src="https://github.com/user-attachments/assets/3d4ce356-1c22-4e3b-a1d0-fece351d9fbb"
/>
<img width="463" height="133" alt="스크린샷 2026-06-16 오후 6 02 33"
src="https://github.com/user-attachments/assets/6ae13a44-02aa-4167-8878-4906db468ad6"
/>
2026-06-17 13:31:07 +00:00

533 lines
15 KiB
TypeScript

import { createPinia, setActivePinia } from 'pinia'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useErrorOverlayState } from './useErrorOverlayState'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type { NodeError } from '@/schemas/apiSchema'
import type {
MissingPackGroup,
SwapNodeGroup
} from '@/components/rightSidePanel/errors/useErrorGroups'
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import type { MissingModelGroup } from '@/platform/missingModel/types'
const mockErrorGroups = vi.hoisted(() => ({
allErrorGroups: { value: [] as ErrorGroup[] },
missingPackGroups: { value: [] as MissingPackGroup[] },
missingModelGroups: { value: [] as MissingModelGroup[] },
missingMediaGroups: { value: [] as MissingMediaGroup[] },
swapNodeGroups: { value: [] as SwapNodeGroup[] }
}))
const mockAllErrorGroups = mockErrorGroups.allErrorGroups
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
useErrorGroups: () => mockErrorGroups
}))
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
useNodeErrorFlagSync: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
isGraphReady: false,
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
executionIdToNodeLocatorId: vi.fn((id: string) => id),
getActiveGraphNodeIds: vi.fn(() => new Set()),
getExecutionIdByNode: vi.fn(),
getNodeByExecutionId: vi.fn()
}))
function createTestI18n() {
return createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
errorOverlay: {
multipleErrorCount: '{count} error found | {count} errors found',
multipleErrorsMessage: 'Resolve them before running the workflow.'
}
}
}
})
}
function makeNodeError(messages: string[]): NodeError {
return {
class_type: 'KSampler',
dependent_outputs: [],
errors: messages.map((message) => ({
type: 'execution_error',
message,
details: 'details'
}))
}
}
function mountOverlayState() {
const pinia = createPinia()
setActivePinia(pinia)
const Harness = defineComponent({
setup() {
return useErrorOverlayState()
},
template: `
<section>
<span data-testid="visible">{{ isVisible }}</span>
<span data-testid="title">{{ overlayTitle }}</span>
<span data-testid="message">{{ overlayMessage }}</span>
</section>
`
})
return render(Harness, {
global: {
plugins: [pinia, createTestI18n()]
}
})
}
describe('useErrorOverlayState', () => {
beforeEach(() => {
mockAllErrorGroups.value = []
mockErrorGroups.missingPackGroups.value = []
mockErrorGroups.missingModelGroups.value = []
mockErrorGroups.missingMediaGroups.value = []
mockErrorGroups.swapNodeGroups.value = []
})
it('uses the raw message for a single uncataloged execution error', async () => {
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Execution failed',
count: 1,
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [{ message: 'Only error' }]
}
]
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('visible')).toHaveTextContent('true')
expect(screen.getByTestId('title')).toHaveTextContent('Execution failed')
expect(screen.getByTestId('message')).toHaveTextContent('Only error')
})
it('uses toast copy for a single validation error', async () => {
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Required input is missing',
count: 1,
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [
{
message: 'Required input is missing',
toastTitle: 'Required input missing',
toastMessage: 'KSampler is missing a required input: model'
}
]
}
]
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Required input is missing'])
}
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent(
'Required input missing'
)
expect(screen.getByTestId('message')).toHaveTextContent(
'KSampler is missing a required input: model'
)
})
it('uses display copy before raw copy when toast copy is absent', async () => {
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Friendly validation title',
count: 1,
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [
{
message: 'Raw validation error',
displayMessage: 'Friendly validation message'
}
]
}
]
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Raw validation error'])
}
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent(
'Friendly validation title'
)
expect(screen.getByTestId('message')).toHaveTextContent(
'Friendly validation message'
)
})
it('uses toast copy for a single runtime error', async () => {
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Generation failed',
count: 1,
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [
{
message: 'torch.OutOfMemoryError: CUDA out of memory',
toastTitle: 'Generation failed',
toastMessage:
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
}
]
}
]
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastExecutionError = {
prompt_id: 'prompt',
node_id: 1,
node_type: 'KSampler',
executed: [],
exception_message: 'CUDA out of memory',
exception_type: 'torch.OutOfMemoryError',
traceback: [],
timestamp: Date.now()
}
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent('Generation failed')
expect(screen.getByTestId('message')).toHaveTextContent(
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
)
})
it('uses group toast copy for a single missing media error', async () => {
mockErrorGroups.missingMediaGroups.value = [
{
mediaType: 'image',
items: [
{
name: 'image.png',
mediaType: 'image',
representative: {
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'image.png',
isMissing: true
},
referencingNodes: [
{
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image'
}
]
}
]
}
]
mockAllErrorGroups.value = [
{
type: 'missing_media',
groupKey: 'missing_media',
displayTitle: 'Media input missing',
displayMessage: 'A required media input has no file selected.',
toastTitle: 'Media input missing',
toastMessage: 'Load Image is missing a required media file.',
count: 1,
priority: 3
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
const missingMediaStore = useMissingMediaStore()
missingMediaStore.setMissingMedia([
{
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'image.png',
isMissing: true
}
])
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent('Media input missing')
expect(screen.getByTestId('message')).toHaveTextContent(
'Load Image is missing a required media file.'
)
})
it('uses group copy for one missing model referenced by multiple nodes', async () => {
mockErrorGroups.missingModelGroups.value = [
{
directory: 'checkpoints',
isAssetSupported: true,
models: [
{
name: 'missing.safetensors',
representative: {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'missing.safetensors',
directory: 'checkpoints',
isAssetSupported: true,
isMissing: true
},
referencingNodes: [
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
]
}
]
}
]
mockAllErrorGroups.value = [
{
type: 'missing_model',
groupKey: 'missing_model',
displayTitle: 'Missing Models',
displayMessage: 'Import a model, or open the node to replace it.',
toastTitle: 'Model missing',
toastMessage: 'CheckpointLoaderSimple is missing missing.safetensors.',
count: 1,
priority: 2
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent('Missing Models')
expect(screen.getByTestId('message')).toHaveTextContent(
'Import a model, or open the node to replace it.'
)
})
it('uses group copy for one execution group with multiple errors', async () => {
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:required_input_missing',
displayTitle: 'Missing connection',
displayMessage: 'Required input slots have no connection feeding them.',
count: 2,
priority: 1,
cards: [
{
id: '1',
title: 'KSampler',
errors: [
{ message: 'KSampler is missing model' },
{ message: 'KSampler is missing positive' }
]
}
]
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent('Missing connection')
expect(screen.getByTestId('message')).toHaveTextContent(
'Required input slots have no connection feeding them.'
)
})
it('uses aggregate copy for one missing model group with multiple rows', async () => {
mockErrorGroups.missingModelGroups.value = [
{
directory: 'checkpoints',
isAssetSupported: true,
models: [
{
name: 'first.safetensors',
representative: {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'first.safetensors',
directory: 'checkpoints',
isAssetSupported: true,
isMissing: true
},
referencingNodes: [{ nodeId: '1', widgetName: 'ckpt_name' }]
},
{
name: 'second.safetensors',
representative: {
nodeId: '2',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'second.safetensors',
directory: 'checkpoints',
isAssetSupported: true,
isMissing: true
},
referencingNodes: [{ nodeId: '2', widgetName: 'ckpt_name' }]
}
]
}
]
mockAllErrorGroups.value = [
{
type: 'missing_model',
groupKey: 'missing_model',
displayTitle: 'Missing Models',
displayMessage: 'Import a model, or open the node to replace it.',
toastTitle: 'Missing models',
toastMessage: '2 model files are missing.',
count: 2,
priority: 2
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent('2 errors found')
expect(screen.getByTestId('message')).toHaveTextContent(
'Resolve them before running the workflow.'
)
})
it('does not show when a raw error has no resolved overlay message', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('visible')).toHaveTextContent('false')
expect(screen.getByTestId('message')).toBeEmptyDOMElement()
})
it('uses grouped error counts for aggregate copy', async () => {
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Execution failed',
displayMessage: 'First group message',
count: 2,
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [{ message: 'First error' }]
}
]
},
{
type: 'execution',
groupKey: 'execution:CLIPTextEncode',
displayTitle: 'Invalid CLIP input',
displayMessage: 'Second group message',
count: 3,
priority: 1,
cards: [
{
id: '2',
title: 'CLIPTextEncode',
errors: [{ message: 'Second error' }]
}
]
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('visible')).toHaveTextContent('true')
expect(screen.getByTestId('title')).toHaveTextContent('5 errors found')
expect(screen.getByTestId('message')).toHaveTextContent(
'Resolve them before running the workflow.'
)
})
})