mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-27 16:34:40 +00:00
## 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`.
910 lines
29 KiB
TypeScript
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
|
|
)
|
|
})
|
|
})
|
|
})
|