Files
ComfyUI_frontend/src/stores/executionErrorStore.test.ts
jaeone94 a0e518aa98 refactor(node-replacement): reorganize domain components and expand comprehensive test suite (#9301)
## Summary

Resolves six open issues by reorganizing node replacement components
into a domain-driven folder structure, refactoring event handling to
follow the emit pattern, and adding comprehensive test coverage across
all affected modules.

## Changes

- **What**:
- Moved `SwapNodeGroupRow.vue` and `SwapNodesCard.vue` from
`src/components/rightSidePanel/errors/` to
`src/platform/nodeReplacement/components/` (Issues #9255)
- Moved `useMissingNodeScan.ts` from `src/composables/` to
`src/platform/nodeReplacement/missingNodeScan.ts`, renamed to reflect it
is a plain function not a Vue composable (Issues #9254)
- Refactored `SwapNodeGroupRow.vue` to emit a `'replace'` event instead
of calling `useNodeReplacement()` and `useExecutionErrorStore()`
directly; replacement logic now handled in `TabErrors.vue` (Issue #9267)
- Added unit tests for `removeMissingNodesByType`
(`executionErrorStore.test.ts`), `scanMissingNodes`
(`missingNodeScan.test.ts`), and `swapNodeGroups` computed
(`swapNodeGroups.test.ts`, `useErrorGroups.test.ts`) (Issue #9270)
- Added placeholder detection tests covering unregistered-type detection
when `has_errors` is false, and exclusion of registered types
(`useNodeReplacement.test.ts`) (Issue #9271)
- Added component tests for `MissingNodeCard` and `MissingPackGroupRow`
covering rendering, expand/collapse, events, install states, and edge
cases (Issue #9231)
- Added component tests for `SwapNodeGroupRow` and `SwapNodesCard`
(Issues #9255, #9267)

## Additional Changes (Post-Review)

- **Edge case guard in placeholder detection**
(`useNodeReplacement.ts`): When `last_serialization.type` is absent (old
serialization format), the predicate falls back to `n.type`, which the
app may have already run through `sanitizeNodeName` — stripping HTML
special characters (`& < > " ' \` =`). In that case, a `Set.has()`
lookup against the original unsanitized type name would silently miss,
causing replacement to be skipped.

Fixed by including sanitized variants of each target type in the
`targetTypes` Set at construction time. For the overwhelmingly common
case (no special characters in type names), the Set deduplicates the
entries and runtime behavior is identical to before.

A regression test was added to cover the specific scenario:
`last_serialization.type` absent + live `n.type` already sanitized.

## Review Focus

- `TabErrors.vue`: confirm the new `@replace` event handler correctly
replaces nodes and removes them from missing nodes list (mirrors the old
inline logic in `SwapNodeGroupRow`)
- `missingNodeScan.ts`: filename/export name change from
`useMissingNodeScan` — verify all call sites updated via `app.ts`
- Test mocking strategy: module-level `vi.mock()` factories use closures
over `ref`/plain objects to allow per-test overrides without global
mutable state

- Fixes #9231
- Fixes #9254
- Fixes #9255
- Fixes #9267
- Fixes #9270
- Fixes #9271
2026-02-28 06:17:30 -08:00

160 lines
5.0 KiB
TypeScript

import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
// Mock dependencies
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => false)
}))
}))
import { useExecutionErrorStore } from './executionErrorStore'
describe('executionErrorStore — missing node operations', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('setMissingNodeTypes', () => {
it('sets missingNodesError with provided types', () => {
const store = useExecutionErrorStore()
const types: MissingNodeType[] = [
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
]
store.setMissingNodeTypes(types)
expect(store.missingNodesError).not.toBeNull()
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
expect(store.hasMissingNodes).toBe(true)
})
it('clears missingNodesError when given empty array', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
expect(store.missingNodesError).not.toBeNull()
store.setMissingNodeTypes([])
expect(store.missingNodesError).toBeNull()
expect(store.hasMissingNodes).toBe(false)
})
it('deduplicates string entries by value', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
'NodeA',
'NodeA',
'NodeB'
] as MissingNodeType[])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('deduplicates object entries by nodeId when present', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '2', isReplaceable: false }
])
// Same nodeId='1' deduplicated, nodeId='2' kept
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('deduplicates object entries by type when nodeId is absent', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', isReplaceable: false },
{ type: 'NodeA', isReplaceable: true }
] as MissingNodeType[])
// Same type, no nodeId → deduplicated
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('keeps distinct nodeIds even when type is the same', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '2', isReplaceable: false },
{ type: 'NodeA', nodeId: '3', isReplaceable: false }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(3)
})
})
describe('removeMissingNodesByType', () => {
it('removes matching types from the missing nodes list', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
])
store.removeMissingNodesByType(['NodeA', 'NodeC'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
const remaining = store.missingNodesError?.nodeTypes[0]
expect(typeof remaining !== 'string' && remaining?.type).toBe('NodeB')
})
it('clears missingNodesError when all types are removed', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
store.removeMissingNodesByType(['NodeA'])
expect(store.missingNodesError).toBeNull()
expect(store.hasMissingNodes).toBe(false)
})
it('does nothing when missingNodesError is null', () => {
const store = useExecutionErrorStore()
expect(store.missingNodesError).toBeNull()
// Should not throw
store.removeMissingNodesByType(['NodeA'])
expect(store.missingNodesError).toBeNull()
})
it('does nothing when removing non-existent types', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
store.removeMissingNodesByType(['NonExistent'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('handles removing from string entries', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
'StringNodeA',
'StringNodeB'
] as MissingNodeType[])
store.removeMissingNodesByType(['StringNodeA'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
})
})