mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
## 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" />
533 lines
15 KiB
TypeScript
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.'
|
|
)
|
|
})
|
|
})
|