Files
ComfyUI_frontend/src/components/rightSidePanel/errors/useErrorGroups.test.ts
jaeone94 c0ef283a05 General execution error messaging (#12448)
## Summary

This PR builds on the error catalog display resolver foundation from
#12402 and adds the first broader catalog pass for general
execution-related messaging. The goal is to keep the raw API contract
intact (`message` / `details`) while adding resolved display fields that
the UI can prefer when catalog copy exists.

The main functional sample in this PR is validation error messaging. It
expands the resolver beyond `required_input_missing` so common node
validation failures can show friendlier titles, grouped messages, detail
copy, item labels, and toast copy without overwriting the original
backend payload.

## What changed

- Added catalog copy for known node validation errors:
  - `required_input_missing` as `missing_connection`
  - `bad_linked_input`
  - `return_type_mismatch`
  - `invalid_input_type`
  - `value_smaller_than_min`
  - `value_bigger_than_max`
  - `value_not_in_list`
  - `custom_validation_failed`
  - `exception_during_inner_validation`
  - `exception_during_validation`
  - `dependency_cycle`
- Added an `image_not_loaded` validation override for
`custom_validation_failed` messages that indicate invalid image files or
directory paths.
- Added value-aware validation details when Core provides structured
`extra_info`, including received values, expected/received types, and
min/max bounds.
- Added prompt-level catalog handling for known prompt errors that
already have stable types/copy, including missing node type, prompt
output validation, image download, and OOM prompt errors.
- Preserved runtime execution errors as raw API copy for now, so
service-level or actionable runtime failures are not hidden behind
generic catalog text before targeted runtime handling lands.
- Added/updated English `errorCatalog` i18n keys for the new validation
and prompt catalog copy.
- Added resolver and grouping tests for the new catalog paths, raw
fallback behavior, runtime raw preservation, prompt copy, and
image-not-loaded detection.

## Screenshots (diff)
### Before  
<img width="371" height="346" alt="Old_1"
src="https://github.com/user-attachments/assets/bd474869-7428-4f68-a067-bb412aa95d3b"
/>
<img width="373" height="296" alt="Old_2"
src="https://github.com/user-attachments/assets/fc393792-dc6d-46fb-b7df-20290b35e30e"
/>
<img width="370" height="292" alt="Old_3"
src="https://github.com/user-attachments/assets/bcb867ea-12ba-49b7-887a-ce06afa60475"
/>
<img width="370" height="269" alt="Old_4"
src="https://github.com/user-attachments/assets/05caeff8-2597-4c95-97cf-2736825b85f3"
/>
<img width="371" height="292" alt="Old_5"
src="https://github.com/user-attachments/assets/dd58113e-5953-4701-b597-d59cb6e124e9"
/>
<img width="373" height="282" alt="Old_6"
src="https://github.com/user-attachments/assets/60fb02c0-4ed6-4734-926c-f8a20f0aeb1c"
/>
<img width="371" height="279" alt="Old_7"
src="https://github.com/user-attachments/assets/a3453b5c-c779-4f43-af27-97cc9a083480"
/>
<img width="370" height="292" alt="Old_8"
src="https://github.com/user-attachments/assets/59d08636-c1b3-4cde-a340-befb48726ee8"
/>
<img width="371" height="276" alt="Old_9"
src="https://github.com/user-attachments/assets/7a94465b-ed5c-4ad9-a40a-cfe3c08d3dc7"
/>
<img width="368" height="279" alt="Old_10"
src="https://github.com/user-attachments/assets/3f791ff3-e3e3-4cb7-aab1-640ec1cee751"
/>
<img width="370" height="276" alt="Old_11"
src="https://github.com/user-attachments/assets/9c0f28c2-4f60-4f38-b3c4-5560609e329e"
/>
<img width="370" height="279" alt="Old_12"
src="https://github.com/user-attachments/assets/4b61545e-db7e-4512-b300-e883ab37f347"
/>

### After
<img width="426" height="301" alt="New_1"
src="https://github.com/user-attachments/assets/9874c036-2b3d-4b7c-ac3d-cb9c396c597f"
/>
<img width="421" height="301" alt="New_2"
src="https://github.com/user-attachments/assets/38cd0f35-53a4-490a-b47f-da21eaa44fc8"
/>
<img width="418" height="347" alt="New_3"
src="https://github.com/user-attachments/assets/db5ab3cc-f246-407d-b80b-9ad92c95c7ad"
/>
<img width="425" height="327" alt="New_4"
src="https://github.com/user-attachments/assets/4333c2b8-3077-4122-9719-21d56a7b2230"
/>
<img width="424" height="325" alt="New_5"
src="https://github.com/user-attachments/assets/6616d61f-fa90-4d2f-b8fd-50ac5a3f32cb"
/>
<img width="423" height="326" alt="New_6"
src="https://github.com/user-attachments/assets/02a4f97a-708e-4c00-b061-d8e4dcaacd8f"
/>
<img width="424" height="323" alt="New_7"
src="https://github.com/user-attachments/assets/9d1e96c9-69de-4e26-a152-1a101675c5eb"
/>
<img width="425" height="327" alt="New_8"
src="https://github.com/user-attachments/assets/ffa66faf-1a33-43a3-b604-25352195f28c"
/>
<img width="425" height="323" alt="New_9"
src="https://github.com/user-attachments/assets/f7eb5f0c-4d0c-4f1b-aa3d-30358fbc9943"
/>
<img width="423" height="328" alt="New_10"
src="https://github.com/user-attachments/assets/72665c97-ec61-4e5a-b702-379baf919822"
/>
<img width="423" height="351" alt="New_11"
src="https://github.com/user-attachments/assets/c5376f02-7a62-42e6-9cda-e50ab6d41b04"
/>
<img width="425" height="326" alt="New_12"
src="https://github.com/user-attachments/assets/413df105-dc7e-4289-90b0-30ecaa417c84"
/>


## Intentional boundaries


This PR does not add targeted runtime/cloud-specific message matching
yet. Runtime execution errors still use the original exception message
and traceback in the error panel. This is intentional because
cloud/service runtime errors can include actionable strings such as
auth, payment, rate limit, timeout, moderation, or infrastructure
failures, and collapsing those too early would make the UX worse.

This PR also does not change the overlay or right-side panel design. It
only prepares and fills resolved display fields so the next stacked PRs
can consume them with much less plumbing.

## Follow-up PR plan

- Add targeted runtime/cloud-specific messaging for high-volume errors
such as credits, timeouts, disallowed content, rate limit,
sign-in/payment requirements, and server crash style failures.
- Revisit runtime execution grouping once runtime catalog IDs are
explicit enough to group by message category rather than node class or
raw exception text.
- Update the error overlay to use single-error toast title/message
fields and multi-error aggregate copy.
- Update the right-side error panel design, including item labels such
as `Node name - input/widget name`.
- Consider splitting `errorMessageResolver.ts` by error family
(`validation`, `prompt`, `runtime`, `cloud-specific`) before adding more
runtime-specific rules.

## Validation

- `pnpm exec vitest run
src/platform/errorCatalog/errorMessageResolver.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts`
- `pnpm typecheck`
- Commit hooks ran staged formatting, lint fixes, and `pnpm typecheck`.
- Push hook ran `knip --cache`; it completed with an existing tag-hint
warning for `src/scripts/metadata/flac.ts`.
2026-05-25 12:54:47 +00:00

910 lines
29 KiB
TypeScript

import { fromAny } from '@total-typescript/shoehorn'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getExecutionIdByNode: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
const mockIsCloud = vi.hoisted(() => ({ value: false }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
vi.mock('@/i18n', () => {
const messages: Record<string, string> = {
'errorCatalog.validationErrors.required_input_missing.title':
'Missing connection',
'errorCatalog.validationErrors.required_input_missing.message':
'Required input slots have no connection feeding them.',
'errorCatalog.validationErrors.required_input_missing.details':
'{nodeName} is missing a required input: {inputName}',
'errorCatalog.validationErrors.required_input_missing.itemLabel':
'{nodeName} - {inputName}',
'errorCatalog.validationErrors.required_input_missing.toastTitle':
'Required input missing',
'errorCatalog.validationErrors.required_input_missing.toastMessage':
'{nodeName} is missing a required input: {inputName}',
'errorCatalog.promptErrors.prompt_no_outputs.title':
'Prompt has no outputs',
'errorCatalog.promptErrors.prompt_no_outputs.desc':
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
}
const interpolate = (
message: string,
params?: Record<string, string | number>
) =>
message.replace(/\{(\w+)\}/g, (match, paramName) =>
params?.[paramName] === undefined ? match : String(params[paramName])
)
return {
te: vi.fn((key: string) => key in messages),
st: vi.fn((key: string, fallback: string) => messages[key] ?? fallback),
t: vi.fn((key: string, params?: Record<string, string | number>) => {
if (key === 'errorOverlay.missingModels') {
const count = Number(params?.count ?? 0)
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
}
return interpolate(messages[key] ?? key, params)
})
}
})
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: () => ({
inferPackFromNodeName: vi.fn()
})
}))
vi.mock('@/utils/nodeTitleUtil', () => ({
resolveNodeDisplayName: vi.fn(() => '')
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => false)
}))
vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
vi.mock(
'@/platform/missingModel/composables/useMissingModelInteractions',
() => ({
clearMissingModelState: vi.fn()
})
)
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
type: string,
opts: {
nodeId?: string
isReplaceable?: boolean
cnrId?: string
replacement?: { new_node_id: string }
} = {}
): MissingNodeType {
return {
type,
nodeId: opts.nodeId ?? '1',
isReplaceable: opts.isReplaceable ?? false,
cnrId: opts.cnrId,
replacement: opts.replacement
? {
old_node_id: type,
new_node_id: opts.replacement.new_node_id,
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
: undefined
}
}
function makeModel(
name: string,
opts: {
nodeId?: string | number
widgetName?: string
directory?: string
isAssetSupported?: boolean
} = {}
) {
return {
name,
nodeId: opts.nodeId ?? '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: opts.widgetName ?? 'ckpt_name',
isAssetSupported: opts.isAssetSupported ?? false,
isMissing: true as const,
directory: opts.directory
}
}
function createErrorGroups() {
const store = useExecutionErrorStore()
const searchQuery = ref('')
const groups = useErrorGroups(searchQuery)
return { store, searchQuery, groups }
}
describe('useErrorGroups', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('missingPackGroups', () => {
it('returns empty array when no missing nodes', () => {
const { groups } = createErrorGroups()
expect(groups.missingPackGroups.value).toEqual([])
})
it('groups non-replaceable nodes by cnrId', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(2)
const pack1 = groups.missingPackGroups.value.find(
(g) => g.packId === 'pack-1'
)
expect(pack1?.nodeTypes).toHaveLength(2)
const pack2 = groups.missingPackGroups.value.find(
(g) => g.packId === 'pack-2'
)
expect(pack2?.nodeTypes).toHaveLength(1)
})
it('excludes replaceable nodes from missingPackGroups', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBe('some-pack')
})
it('groups nodes without cnrId under null packId', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBeNull()
expect(groups.missingPackGroups.value[0].nodeTypes).toHaveLength(2)
})
it('sorts groups alphabetically with null packId last', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
makeMissingNodeType('NodeB', { nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
])
await nextTick()
const packIds = groups.missingPackGroups.value.map((g) => g.packId)
expect(packIds).toEqual(['alpha-pack', 'zebra-pack', null])
})
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
])
await nextTick()
const group = groups.missingPackGroups.value[0]
const types = group.nodeTypes.map((n) =>
typeof n === 'string' ? n : `${n.type}:${n.nodeId}`
)
expect(types).toEqual(['NodeA:1', 'NodeA:3', 'NodeB:2'])
})
it('handles string nodeType entries', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
fromAny<MissingNodeType, unknown>('StringGroupNode')
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBeNull()
})
})
describe('allErrorGroups', () => {
it('returns empty array when no errors', () => {
const { groups } = createErrorGroups()
expect(groups.allErrorGroups.value).toEqual([])
})
it('includes missing_node group when missing nodes exist', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
expect(missingGroup?.groupKey).toBe('missing_node')
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
expect(missingGroup?.displayMessage).toBe(
'Some nodes are missing and need to be installed'
)
})
it('includes swap_nodes group when replaceable nodes exist', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
})
])
await nextTick()
const swapGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'swap_nodes'
)
expect(swapGroup).toBeDefined()
})
it('includes both swap_nodes and missing_node when both exist', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
const types = groups.allErrorGroups.value.map((g) => g.type)
expect(types).toContain('swap_nodes')
expect(types).toContain('missing_node')
})
it('swap_nodes has lower priority than missing_node', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
const swapIdx = groups.allErrorGroups.value.findIndex(
(g) => g.type === 'swap_nodes'
)
const missingIdx = groups.allErrorGroups.value.findIndex(
(g) => g.type === 'missing_node'
)
expect(swapIdx).toBeLessThan(missingIdx)
})
it('includes execution error groups from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'value_not_valid',
message: 'Value not valid',
details: 'some detail'
}
]
}
}
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
expect(execGroups[0].groupKey).toBe('execution:KSampler')
expect(execGroups[0].displayTitle).toBe('KSampler')
})
it('resolves required_input_missing item display copy', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'model',
extra_info: {
input_name: 'model'
}
}
]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.type).toBe('execution')
if (execGroup?.type !== 'execution') return
const card = execGroup.cards[0]
const error = card.errors[0]
expect(error.message).toBe('Required input is missing')
expect(error.details).toBe('model')
expect(error.catalogId).toBe('missing_connection')
expect(error.displayTitle).toBe('Missing connection')
expect(error.displayMessage).toBe(
'Required input slots have no connection feeding them.'
)
expect(error.displayDetails).toBe(
'KSampler is missing a required input: model'
)
expect(error.displayItemLabel).toBe('KSampler - model')
expect(error.toastTitle).toBe('Required input missing')
expect(error.toastMessage).toBe(
'KSampler is missing a required input: model'
)
})
it('includes execution error from runtime errors', async () => {
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
node_type: 'KSampler',
executed: [],
exception_type: 'RuntimeError',
exception_message: 'CUDA out of memory',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
if (execGroups[0].type !== 'execution') return
expect(execGroups[0].cards[0].errors[0]).toMatchObject({
message: 'RuntimeError: CUDA out of memory',
details: 'line 1\nline 2',
isRuntimeError: true,
exceptionType: 'RuntimeError'
})
// TODO(FE-816 overlay-redesign): Runtime execution errors intentionally
// bypass catalog display fields until targeted runtime handling lands.
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBeUndefined()
expect(execGroups[0].cards[0].errors[0].toastTitle).toBeUndefined()
})
it('includes prompt error when present', async () => {
const { store, groups } = createErrorGroups()
store.lastPromptError = {
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
}
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
(g) =>
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
)
expect(promptGroup).toBeDefined()
})
it('sorts cards within an execution group by nodeId numerically', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'10': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'2': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
const nodeIds = execGroup?.cards.map((c) => c.nodeId)
expect(nodeIds).toEqual(['1', '2', '10'])
})
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'2': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1:20': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
const nodeIds = execGroup?.cards.map((c) => c.nodeId)
expect(nodeIds).toEqual(['1', '1:20', '2'])
})
it('sorts deeply nested nodeIds by each segment numerically', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'10:11:99': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'10:11:12': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'10:2': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
const nodeIds = execGroup?.cards.map((c) => c.nodeId)
expect(nodeIds).toEqual(['10:2', '10:11:12', '10:11:99'])
})
})
describe('filteredGroups', () => {
it('returns all groups when search query is empty', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
await nextTick()
expect(groups.filteredGroups.value.length).toBeGreaterThan(0)
})
it('filters groups based on search query', async () => {
const { store, groups, searchQuery } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'value_error',
message: 'Value error in sampler',
details: ''
}
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{
type: 'file_not_found',
message: 'File not found',
details: ''
}
]
}
}
await nextTick()
searchQuery.value = 'sampler'
await nextTick()
const executionGroups = groups.filteredGroups.value.filter(
(g) => g.type === 'execution'
)
for (const group of executionGroups) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(c) =>
c.title.toLowerCase().includes('sampler') ||
c.errors.some((e) => e.message.toLowerCase().includes('sampler'))
)
expect(hasMatch).toBe(true)
}
})
})
describe('groupedErrorMessages', () => {
it('returns empty array when no errors', () => {
const { groups } = createErrorGroups()
expect(groups.groupedErrorMessages.value).toEqual([])
})
it('collects unique error messages from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{ type: 'err_a', message: 'Error A', details: '' },
{ type: 'err_b', message: 'Error B', details: '' }
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [{ type: 'err_a', message: 'Error A', details: '' }]
}
}
await nextTick()
const messages = groups.groupedErrorMessages.value
expect(messages).toContain('Error A')
expect(messages).toContain('Error B')
// Deduplication: Error A appears twice but should only be listed once
expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
})
it('includes missing node group display message', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
expect(groups.groupedErrorMessages.value).toContain(
missingGroup!.displayMessage
)
})
})
describe('missingModelGroups', () => {
it('returns empty array when no missing models', () => {
const { groups } = createErrorGroups()
expect(groups.missingModelGroups.value).toEqual([])
})
it('groups asset-supported models by directory', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('model_a.safetensors', {
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('model_b.safetensors', {
nodeId: '2',
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('lora_a.safetensors', {
nodeId: '3',
directory: 'loras',
isAssetSupported: true
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(2)
const ckptGroup = groups.missingModelGroups.value.find(
(g) => g.directory === 'checkpoints'
)
expect(ckptGroup?.models).toHaveLength(2)
expect(ckptGroup?.isAssetSupported).toBe(true)
})
it('puts unsupported models in a separate group', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('model_a.safetensors', {
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('custom_model.safetensors', {
nodeId: '2',
isAssetSupported: false
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(2)
const unsupported = groups.missingModelGroups.value.find(
(g) => !g.isAssetSupported
)
expect(unsupported?.models).toHaveLength(1)
})
it('merges same-named models into one view model with multiple referencingNodes', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('shared_model.safetensors', {
nodeId: '1',
widgetName: 'ckpt_name',
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('shared_model.safetensors', {
nodeId: '2',
widgetName: 'ckpt_name',
directory: 'checkpoints',
isAssetSupported: true
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(1)
const model = groups.missingModelGroups.value[0].models[0]
expect(model.name).toBe('shared_model.safetensors')
expect(model.referencingNodes).toHaveLength(2)
})
it('groups non-asset-supported models by directory in OSS', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('model_a.safetensors', {
directory: 'checkpoints',
isAssetSupported: false
}),
makeModel('model_b.safetensors', {
nodeId: '2',
directory: 'checkpoints',
isAssetSupported: false
}),
makeModel('lora_a.safetensors', {
nodeId: '3',
directory: 'loras',
isAssetSupported: false
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(2)
const ckptGroup = groups.missingModelGroups.value.find(
(g) => g.directory === 'checkpoints'
)
expect(ckptGroup?.models).toHaveLength(2)
expect(ckptGroup?.isAssetSupported).toBe(false)
const loraGroup = groups.missingModelGroups.value.find(
(g) => g.directory === 'loras'
)
expect(loraGroup?.models).toHaveLength(1)
})
it('does not lump non-asset-supported models into UNSUPPORTED group in OSS', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('model_a.safetensors', {
directory: 'checkpoints',
isAssetSupported: false
}),
makeModel('lora_a.safetensors', {
nodeId: '2',
directory: 'loras',
isAssetSupported: false
})
])
await nextTick()
const unsupported = groups.missingModelGroups.value.find(
(g) => g.directory === null && !g.isAssetSupported
)
expect(unsupported).toBeUndefined()
})
it('includes missing_model group in allErrorGroups', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([makeModel('model_a.safetensors')])
await nextTick()
const modelGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_model'
)
expect(modelGroup).toBeDefined()
expect(modelGroup?.groupKey).toBe('missing_model')
expect(modelGroup?.displayTitle).toBe('Missing Models (1)')
})
})
describe('missingModelGroups (Cloud)', () => {
beforeEach(() => {
mockIsCloud.value = true
})
afterEach(() => {
mockIsCloud.value = false
})
it('puts unsupported models into UNSUPPORTED group in Cloud', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('model_a.safetensors', {
directory: 'checkpoints',
isAssetSupported: false
}),
makeModel('model_b.safetensors', {
nodeId: '2',
directory: 'loras',
isAssetSupported: false
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(1)
expect(groups.missingModelGroups.value[0].isAssetSupported).toBe(false)
expect(groups.missingModelGroups.value[0].directory).toBeNull()
})
it('groups asset-supported models by directory in Cloud', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('model_a.safetensors', {
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('model_b.safetensors', {
nodeId: '2',
directory: 'loras',
isAssetSupported: true
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(2)
expect(
groups.missingModelGroups.value.every((g) => g.isAssetSupported)
).toBe(true)
})
})
describe('unfiltered vs selection-filtered model/media groups', () => {
it('exposes both unfiltered (missingModelGroups) and filtered (filteredMissingModelGroups)', () => {
const { groups } = createErrorGroups()
expect(groups.missingModelGroups).toBeDefined()
expect(groups.filteredMissingModelGroups).toBeDefined()
expect(groups.missingMediaGroups).toBeDefined()
expect(groups.filteredMissingMediaGroups).toBeDefined()
})
it('missingModelGroups returns total candidates regardless of selection (ErrorOverlay contract)', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('a.safetensors', { nodeId: '1', directory: 'checkpoints' }),
makeModel('b.safetensors', { nodeId: '2', directory: 'checkpoints' })
])
// Simulate canvas selection of a single node so the filtered
// variant actually narrows. Without this, both sides return the
// same value trivially and the test can't prove the contract.
vi.mocked(isLGraphNode).mockReturnValue(true)
const canvasStore = useCanvasStore()
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '1' }])
await nextTick()
// Unfiltered total stays at one group of two models regardless of
// the selection — ErrorOverlay reads this for the overlay label
// and must not shrink with canvas selection.
expect(groups.missingModelGroups.value).toHaveLength(1)
expect(groups.missingModelGroups.value[0].models).toHaveLength(2)
// Filtered variant does narrow under the same selection state —
// this is how the errors tab scopes cards to the selected node.
// Exact filtered output depends on the app.rootGraph lookup
// (mocked to return undefined here); what matters is that the
// filtered shape is a different reference and does not blindly
// mirror the unfiltered one.
expect(groups.filteredMissingModelGroups.value).not.toBe(
groups.missingModelGroups.value
)
})
})
})