mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-11 09:42:22 +00:00
## Summary
Selecting a node no longer filters the errors tab down to that node —
the tab now always shows every error in the workflow, and selection
instead *emphasizes* the matching entries (auto-expand, row highlight,
and a context label), so the error count never lies about whether the
workflow can run.
## Why
The errors tab has quietly been playing two roles at once. It is a
**status surface** ("is this workflow runnable? what's broken and how
much?") — that's what the hero count, the panel-button badge, and the
Run warning all lean on. But it also behaves like an **inspector**:
select a node and the whole list silently narrows to that node's errors.
Mixing the two is confusing in a very concrete way: with 3 errors in the
workflow, clicking one error node makes the tab read "1 Error detected".
Fix that one error while it's selected and the tab reads as clean —
while Run would still fail on the other two. The count changes meaning
depending on an invisible condition (selection), and it disagrees with
the global badges right next to it. This is the same reason VS Code's
Problems panel always lists everything and makes "current file only" an
explicit toggle rather than an implicit one.
## Changes
- **What**: Selection now works as emphasis on top of an always-complete
list:
- The hero count and the group list always describe the whole workflow,
regardless of selection.
- Selecting a node with errors auto-expands the groups containing them
and collapses the rest; clearing the selection (or moving it to an
error-free node) restores the expansion. Manual collapse choices are
left alone when a selection never matched anything.
- Matching entries get a background highlight using the design-system
selection blue (`--color-blue-selection`), so the panel emphasis
visually matches the canvas selection color. The highlight fades in/out
and bleeds slightly past the text without shifting any layout. This
works across all error kinds: execution errors, missing models, missing
media, missing node packs, and swap suggestions (each row/pack that
references the selected node is highlighted).
- A **resident context strip** sits between the hero and the list. With
no selection it reads `{n} nodes — {count} errors` as a workflow
summary; while a selection has errors it switches to `{node title} —
{count} errors` (or `{n} nodes selected — …` for multi-select). Because
the strip always occupies its slot, selecting/deselecting never reflows
the list.
- The strip is deliberately **always visible** rather than mounted on
demand: we plan to rename the tab to "Issues" and downgrade the
missing-* categories from errors to warnings, at which point this same
line becomes the mixed status readout (`X nodes — X errors / X
warnings`). Landing it as a resident status line now means that change
is a label swap, not a layout change.
- **Refactor**: the tab body (search, hero, grouped cards,
locate/install/replace handlers) is extracted from `TabErrors.vue` into
a reusable `ErrorGroupList.vue` — a follow-up PR mounts it outside the
sidebar. The old selection-filter machinery is kept internally as
`selectionScopedGroups` and now only derives the emphasis state (matched
group keys / card ids / asset node ids, selection error count). The
orphaned `compact` prop on `ErrorNodeCard` is removed along with it.
- **Fixes along the way**: node titles containing `=`/`&` no longer
render as HTML entities in the strip (title goes through `i18n-t` slots
instead of an escaped `t()` param), untitled nodes fall back to
"Untitled" instead of producing "1 nodes selected", and emphasized rows
expose their state to assistive tech via `aria-current` while the strip
announces via `role="status"`.
## Review Focus
- `useErrorGroups.ts`: the selection-emphasis derivation
(`selectionScopedGroups` and the `selectionMatched*` computeds).
Selection matching resolves execution ids through the graph (and by
container prefix for subgraph selections) rather than comparing raw ids
— the new unit tests pin both paths.
- `ErrorGroupList.vue`: the emphasis watcher (immediate,
membership-signature based) that syncs collapse state, and the strip
mode switch. The component tests cover the expand/collapse/restore
cycle, emphasis for selections that predate mount, and the strip label
states.
- The two e2e tests that previously asserted the filtering behavior now
assert the new one (counts stay global, strip shows the selection-scoped
count, deselect returns to the summary).
Known follow-ups (intentionally out of scope): distinct-node counting
can over/under-count in subgraph + mixed-error edge cases (execution ids
vs serialized ids), snapshot/restore of user collapse state across
emphasis, and narrowing the list-container `aria-live` region.
## Screenshots (if applicable)
Before
https://github.com/user-attachments/assets/ccf98954-83ed-4333-ba4e-31cedb9fc38b
### After
https://github.com/user-attachments/assets/f7317e4b-71c2-4009-94cc-25287979c0e4
https://github.com/user-attachments/assets/9008c100-0ec0-4612-8fe4-942fec1be2fe
https://github.com/user-attachments/assets/45734552-9986-41f8-93ad-5d072e355d3d
1264 lines
42 KiB
TypeScript
1264 lines
42 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'
|
|
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
|
|
|
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 }))
|
|
const unknownValidationMessage = vi.hoisted(
|
|
() => 'A node returned a validation error ComfyUI does not recognize.'
|
|
)
|
|
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.validationErrors.unknown_validation_error.title':
|
|
'Validation failed',
|
|
'errorCatalog.validationErrors.unknown_validation_error.message':
|
|
unknownValidationMessage,
|
|
'errorCatalog.validationErrors.unknown_validation_error.detailsWithRawDetails':
|
|
'{nodeName} returned an unrecognized validation error ({errorType}): {rawDetails}',
|
|
'errorCatalog.validationErrors.unknown_validation_error.itemLabel':
|
|
'{nodeName}',
|
|
'errorCatalog.validationErrors.unknown_validation_error.toastTitle':
|
|
'Validation failed',
|
|
'errorCatalog.validationErrors.unknown_validation_error.toastMessage':
|
|
'{nodeName} returned an unrecognized validation error.',
|
|
'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.',
|
|
'errorCatalog.runtimeErrors.execution_failed.title': 'Execution failed',
|
|
'errorCatalog.runtimeErrors.execution_failed.message':
|
|
'Node threw an error during execution.',
|
|
'errorCatalog.runtimeErrors.execution_failed.itemLabel': '{nodeName}',
|
|
'errorCatalog.runtimeErrors.execution_failed.toastTitle':
|
|
'{nodeName} failed',
|
|
'errorCatalog.runtimeErrors.execution_failed.toastMessage':
|
|
'This node threw an error during execution. Check its inputs or try a different configuration.',
|
|
'errorCatalog.runtimeErrors.out_of_memory.title': 'Generation failed',
|
|
'errorCatalog.runtimeErrors.out_of_memory.message':
|
|
'Not enough GPU memory. Try reducing image resolution or batch size and run again.',
|
|
'errorCatalog.runtimeErrors.out_of_memory.itemLabel': '{nodeName}',
|
|
'errorCatalog.runtimeErrors.out_of_memory.toastTitle': 'Generation failed',
|
|
'errorCatalog.runtimeErrors.out_of_memory.toastMessage':
|
|
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
|
|
}
|
|
|
|
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(
|
|
'@/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 {
|
|
getExecutionIdByNode,
|
|
getNodeByExecutionId
|
|
} from '@/utils/graphTraversalUtil'
|
|
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import { useErrorGroups } from './useErrorGroups'
|
|
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
|
|
|
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 makeMedia(
|
|
name: string,
|
|
opts: {
|
|
nodeId: string | number
|
|
nodeType?: string
|
|
widgetName?: string
|
|
}
|
|
): MissingMediaCandidate {
|
|
return {
|
|
name,
|
|
nodeId: opts.nodeId,
|
|
nodeType: opts.nodeType ?? 'LoadImage',
|
|
widgetName: opts.widgetName ?? 'image',
|
|
mediaType: 'image',
|
|
isMissing: true
|
|
}
|
|
}
|
|
|
|
function createErrorGroups() {
|
|
const store = useExecutionErrorStore()
|
|
const searchQuery = ref('')
|
|
const groups = useErrorGroups(searchQuery)
|
|
return { store, searchQuery, groups }
|
|
}
|
|
|
|
describe('useErrorGroups', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia())
|
|
mockIsCloud.value = false
|
|
vi.mocked(isLGraphNode).mockReturnValue(false)
|
|
vi.mocked(getNodeByExecutionId).mockReset()
|
|
})
|
|
|
|
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')
|
|
expect(missingGroup?.displayMessage).toBe(
|
|
'Install missing packs to use this workflow.'
|
|
)
|
|
})
|
|
|
|
it('uses Cloud copy for missing_node group in Cloud', async () => {
|
|
mockIsCloud.value = true
|
|
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?.displayMessage).toBe(
|
|
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes."
|
|
)
|
|
})
|
|
|
|
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('uses fallback catalog grouping for unknown node validation 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:unknown_validation_error')
|
|
expect(execGroups[0].displayTitle).toBe('Validation failed')
|
|
})
|
|
|
|
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('groups node validation errors by catalog id across node types', 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'
|
|
}
|
|
}
|
|
]
|
|
},
|
|
'2': {
|
|
class_type: 'CLIPLoader',
|
|
dependent_outputs: [],
|
|
errors: [
|
|
{
|
|
type: 'required_input_missing',
|
|
message: 'Required input is missing',
|
|
details: 'clip',
|
|
extra_info: {
|
|
input_name: 'clip'
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
await nextTick()
|
|
|
|
const execGroups = groups.allErrorGroups.value.filter(
|
|
(g) => g.type === 'execution'
|
|
)
|
|
expect(execGroups).toHaveLength(1)
|
|
|
|
const [group] = execGroups
|
|
expect(group.groupKey).toBe('execution:missing_connection')
|
|
expect(group.displayTitle).toBe('Missing connection')
|
|
expect(group.cards.map((card) => card.title)).toEqual([
|
|
'KSampler',
|
|
'CLIPLoader'
|
|
])
|
|
expect(group.cards.flatMap((card) => card.errors)).toHaveLength(2)
|
|
})
|
|
|
|
it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
|
|
mockIsCloud.value = true
|
|
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: 'mat1 and mat2 shapes cannot be multiplied',
|
|
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: mat1 and mat2 shapes cannot be multiplied',
|
|
details: 'line 1\nline 2',
|
|
isRuntimeError: true,
|
|
exceptionType: 'RuntimeError',
|
|
catalogId: 'execution_failed',
|
|
displayTitle: 'Execution failed',
|
|
displayMessage: 'Node threw an error during execution.',
|
|
displayItemLabel: 'KSampler',
|
|
toastTitle: 'KSampler failed',
|
|
toastMessage:
|
|
'This node threw an error during execution. Check its inputs or try a different configuration.'
|
|
})
|
|
})
|
|
|
|
it('adds display fields for targeted runtime execution errors', async () => {
|
|
mockIsCloud.value = true
|
|
const { store, groups } = createErrorGroups()
|
|
store.lastExecutionError = {
|
|
prompt_id: 'test-prompt',
|
|
timestamp: Date.now(),
|
|
node_id: 5,
|
|
node_type: 'KSampler',
|
|
executed: [],
|
|
exception_type: 'torch.OutOfMemoryError',
|
|
exception_message:
|
|
'Allocation on device 0 failed.\nThis error means you ran out of memory on your GPU.',
|
|
traceback: ['line 1', 'line 2'],
|
|
current_inputs: {},
|
|
current_outputs: {}
|
|
}
|
|
await nextTick()
|
|
|
|
const execGroup = groups.allErrorGroups.value.find(
|
|
(g) => g.type === 'execution'
|
|
)
|
|
expect(execGroup?.type).toBe('execution')
|
|
if (execGroup?.type !== 'execution') return
|
|
|
|
const error = execGroup.cards[0].errors[0]
|
|
expect(error.message).toContain('torch.OutOfMemoryError:')
|
|
expect(error.catalogId).toBe('out_of_memory')
|
|
expect(error.displayMessage).toBe(
|
|
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
|
|
)
|
|
expect(error.displayItemLabel).toBe('KSampler')
|
|
expect(error.toastTitle).toBe('Generation failed')
|
|
})
|
|
|
|
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('includes prompt error when a node is selected', async () => {
|
|
const { store, groups } = createErrorGroups()
|
|
const canvasStore = useCanvasStore()
|
|
vi.mocked(isLGraphNode).mockReturnValue(true)
|
|
canvasStore.selectedItems = fromAny<
|
|
typeof canvasStore.selectedItems,
|
|
unknown
|
|
>([{ id: '1' }])
|
|
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('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')
|
|
})
|
|
})
|
|
|
|
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('selection does not shrink displayed groups', () => {
|
|
it('missingModelGroups returns total candidates regardless of selection', async () => {
|
|
const { store, groups } = createErrorGroups()
|
|
store.surfaceMissingModels([
|
|
makeModel('a.safetensors', { nodeId: '1', directory: 'checkpoints' }),
|
|
makeModel('b.safetensors', { nodeId: '2', directory: 'checkpoints' })
|
|
])
|
|
vi.mocked(isLGraphNode).mockReturnValue(true)
|
|
const canvasStore = useCanvasStore()
|
|
canvasStore.selectedItems = fromAny<
|
|
typeof canvasStore.selectedItems,
|
|
unknown
|
|
>([{ id: '1' }])
|
|
await nextTick()
|
|
|
|
// Displayed groups never shrink with canvas selection — the count
|
|
// and list always describe the whole workflow.
|
|
expect(groups.missingModelGroups.value).toHaveLength(1)
|
|
expect(groups.missingModelGroups.value[0].models).toHaveLength(2)
|
|
expect(
|
|
groups.filteredGroups.value.find((g) => g.type === 'missing_model')
|
|
?.count
|
|
).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('missing media counting', () => {
|
|
it('counts missing media by affected node rows, not grouped filenames', async () => {
|
|
const { store, groups } = createErrorGroups()
|
|
store.surfaceMissingMedia([
|
|
makeMedia('shared.png', { nodeId: '1', nodeType: 'LoadImage' }),
|
|
makeMedia('shared.png', { nodeId: '2', nodeType: 'PreviewImage' })
|
|
])
|
|
await nextTick()
|
|
|
|
expect(store.totalErrorCount).toBe(2)
|
|
expect(groups.missingMediaGroups.value).toHaveLength(1)
|
|
expect(groups.missingMediaGroups.value[0].items).toHaveLength(1)
|
|
expect(
|
|
groups.missingMediaGroups.value[0].items[0].referencingNodes
|
|
).toHaveLength(2)
|
|
|
|
const missingMediaGroup = groups.allErrorGroups.value.find(
|
|
(group) => group.type === 'missing_media'
|
|
)
|
|
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs')
|
|
})
|
|
})
|
|
|
|
describe('selection emphasis', () => {
|
|
it('never marks workflow-level prompt errors as matched by a selection', async () => {
|
|
const { store, groups } = createErrorGroups()
|
|
const canvasStore = useCanvasStore()
|
|
vi.mocked(isLGraphNode).mockReturnValue(true)
|
|
canvasStore.selectedItems = fromAny<
|
|
typeof canvasStore.selectedItems,
|
|
unknown
|
|
>([{ id: '1' }])
|
|
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()
|
|
expect(
|
|
groups.selectionMatchedGroupKeys.value.has(promptGroup!.groupKey)
|
|
).toBe(false)
|
|
})
|
|
|
|
it('reports no selection state when nothing is selected', 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.hasSelection.value).toBe(false)
|
|
expect(groups.selectionMatchedGroupKeys.value.size).toBe(0)
|
|
expect(groups.selectionMatchedCardIds.value.size).toBe(0)
|
|
expect(groups.selectionErrorCount.value).toBe(0)
|
|
})
|
|
|
|
it('matches groups and cards of the selected error node', async () => {
|
|
const { store, groups } = createErrorGroups()
|
|
const canvasStore = useCanvasStore()
|
|
vi.mocked(isLGraphNode).mockReturnValue(true)
|
|
const selectedNode = { id: '1' }
|
|
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
|
|
fromAny<LGraphNode, unknown>(
|
|
String(nodeId) === '1' ? selectedNode : { id: String(nodeId) }
|
|
)
|
|
)
|
|
canvasStore.selectedItems = fromAny<
|
|
typeof canvasStore.selectedItems,
|
|
unknown
|
|
>([selectedNode])
|
|
store.lastNodeErrors = {
|
|
'1': {
|
|
class_type: 'KSampler',
|
|
dependent_outputs: [],
|
|
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
|
|
},
|
|
'2': {
|
|
class_type: 'CLIPLoader',
|
|
dependent_outputs: [],
|
|
errors: [
|
|
{ type: 'file_not_found', message: 'File not found', details: '' }
|
|
]
|
|
}
|
|
}
|
|
await nextTick()
|
|
|
|
expect(groups.hasSelection.value).toBe(true)
|
|
expect(groups.selectionErrorCount.value).toBe(1)
|
|
expect(groups.selectionMatchedCardIds.value.has('node-1')).toBe(true)
|
|
expect(groups.selectionMatchedCardIds.value.has('node-2')).toBe(false)
|
|
expect(groups.selectionMatchedAssetNodeIds.value.size).toBe(0)
|
|
// Both error groups remain displayed regardless of the selection
|
|
const executionGroups = groups.filteredGroups.value.filter(
|
|
(g) => g.type === 'execution'
|
|
)
|
|
const displayedCardIds = executionGroups.flatMap((g) =>
|
|
g.type === 'execution' ? g.cards.map((c) => c.id) : []
|
|
)
|
|
expect(displayedCardIds).toContain('node-1')
|
|
expect(displayedCardIds).toContain('node-2')
|
|
})
|
|
|
|
it('narrows missing-node emphasis to packs containing the selected node', async () => {
|
|
const { groups } = createErrorGroups()
|
|
const missingNodesStore = useMissingNodesErrorStore()
|
|
const canvasStore = useCanvasStore()
|
|
vi.mocked(isLGraphNode).mockReturnValue(true)
|
|
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
|
|
fromAny<LGraphNode, unknown>({ id: String(nodeId) })
|
|
)
|
|
canvasStore.selectedItems = fromAny<
|
|
typeof canvasStore.selectedItems,
|
|
unknown
|
|
>([{ id: '2' }])
|
|
missingNodesStore.setMissingNodeTypes([
|
|
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
|
|
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
|
|
])
|
|
await nextTick()
|
|
|
|
// Emphasis counts only the packs containing the selected node…
|
|
expect(groups.selectionMatchedGroupKeys.value.has('missing_node')).toBe(
|
|
true
|
|
)
|
|
expect(groups.selectionErrorCount.value).toBe(1)
|
|
// …and marks only the selected node for row highlighting.
|
|
expect(groups.selectionMatchedAssetNodeIds.value.has('2')).toBe(true)
|
|
expect(groups.selectionMatchedAssetNodeIds.value.has('3')).toBe(false)
|
|
// Display still shows every pack.
|
|
const missingNodeGroup = groups.filteredGroups.value.find(
|
|
(g) => g.type === 'missing_node'
|
|
)
|
|
expect(missingNodeGroup?.count).toBe(2)
|
|
})
|
|
|
|
it('does not emphasize missing-node groups for unrelated selections', async () => {
|
|
const { groups } = createErrorGroups()
|
|
const missingNodesStore = useMissingNodesErrorStore()
|
|
const canvasStore = useCanvasStore()
|
|
vi.mocked(isLGraphNode).mockReturnValue(true)
|
|
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
|
|
fromAny<LGraphNode, unknown>({ id: String(nodeId) })
|
|
)
|
|
canvasStore.selectedItems = fromAny<
|
|
typeof canvasStore.selectedItems,
|
|
unknown
|
|
>([{ id: '99' }])
|
|
missingNodesStore.setMissingNodeTypes([
|
|
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' })
|
|
])
|
|
await nextTick()
|
|
|
|
expect(groups.selectionMatchedGroupKeys.value.has('missing_node')).toBe(
|
|
false
|
|
)
|
|
expect(groups.selectionErrorCount.value).toBe(0)
|
|
// Display is unaffected by the unrelated selection.
|
|
expect(
|
|
groups.filteredGroups.value.find((g) => g.type === 'missing_node')
|
|
?.count
|
|
).toBe(1)
|
|
})
|
|
|
|
it('matches errors through graph resolution, not raw execution ids', async () => {
|
|
const { store, groups } = createErrorGroups()
|
|
const canvasStore = useCanvasStore()
|
|
vi.mocked(isLGraphNode).mockReturnValue(true)
|
|
// The error is keyed by a subgraph execution id ('2:5') that resolves
|
|
// to a different graph node id ('7') at the current graph level.
|
|
const selectedNode = { id: '7' }
|
|
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
|
|
fromAny<LGraphNode, unknown>(
|
|
String(nodeId) === '2:5' ? selectedNode : undefined
|
|
)
|
|
)
|
|
canvasStore.selectedItems = fromAny<
|
|
typeof canvasStore.selectedItems,
|
|
unknown
|
|
>([selectedNode])
|
|
store.lastNodeErrors = {
|
|
'2:5': {
|
|
class_type: 'KSampler',
|
|
dependent_outputs: [],
|
|
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
|
|
}
|
|
}
|
|
await nextTick()
|
|
|
|
expect(groups.selectionErrorCount.value).toBe(1)
|
|
expect(groups.selectionMatchedCardIds.value.has('node-2:5')).toBe(true)
|
|
})
|
|
|
|
it('matches interior errors when a subgraph container is selected', async () => {
|
|
const { store, groups } = createErrorGroups()
|
|
const canvasStore = useCanvasStore()
|
|
vi.mocked(isLGraphNode).mockReturnValue(true)
|
|
// A container selection matches interior errors by execution-id prefix,
|
|
// even when the interior node does not resolve at the current level.
|
|
const containerNode = fromAny<SubgraphNode, unknown>(
|
|
Object.assign(Object.create(SubgraphNode.prototype), { id: '2' })
|
|
)
|
|
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
|
|
vi.mocked(getExecutionIdByNode).mockReturnValue(
|
|
fromAny<NodeExecutionId, unknown>('2')
|
|
)
|
|
canvasStore.selectedItems = fromAny<
|
|
typeof canvasStore.selectedItems,
|
|
unknown
|
|
>([containerNode])
|
|
store.lastNodeErrors = {
|
|
'2:5': {
|
|
class_type: 'KSampler',
|
|
dependent_outputs: [],
|
|
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
|
|
},
|
|
'9': {
|
|
class_type: 'CLIPLoader',
|
|
dependent_outputs: [],
|
|
errors: [
|
|
{ type: 'file_not_found', message: 'File not found', details: '' }
|
|
]
|
|
}
|
|
}
|
|
await nextTick()
|
|
|
|
expect(groups.selectionErrorCount.value).toBe(1)
|
|
expect(groups.selectionMatchedCardIds.value.has('node-2:5')).toBe(true)
|
|
expect(groups.selectionMatchedCardIds.value.has('node-9')).toBe(false)
|
|
})
|
|
})
|
|
})
|