Files
ComfyUI_frontend/src/platform/nodeReplacement/useNodeReplacement.test.ts
Dante f1ea3b02a6 test: add unit tests for useNodeReplacement transfer edge cases (#11677)
## Summary

Extends `useNodeReplacement.test.ts` with five connection-transfer and
graph-mutation edge cases that the existing 23-case suite did not cover:
missing-old-input-slot skip, missing-new-output-index resilience,
set_value on a non-existent widget, set_value with dot-notation new_id,
and the Vue-node refresh path via `nodeGraph.onNodeAdded`.

## Changes

- **What**: Adds 5 Vitest cases in a new `transfer edge cases` describe
block. Reuses the existing `createPlaceholderNode`, `createNewNode`,
`createMockGraph`, `createMockLink`, and `makeMissingNodeType` helpers —
no new test infrastructure introduced.

## Review Focus

- The "missing new output index" test verifies that `replaceWithMapping`
does not throw when `newNode.outputs[newOutputIdx]` is absent, and
asserts the original link's `origin_id` is unchanged so the silent-skip
behavior is pinned (not a swallowed exception).
- The dot-notation `set_value` test pins that the existing dot-notation
guard at `useNodeReplacement.ts:203` covers the `set_value` branch (not
just the `old_id` connection branch already covered at line 187).
- The `onNodeAdded` test asserts the Vue-node sync path that runs after
`replaceWithMapping` bypasses `graph.add()` — a future refactor that
drops the explicit call would silently break the Vue node renderer
otherwise.

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/nodeReplacement/useNodeReplacement.test.ts
pnpm format -- src/platform/nodeReplacement/useNodeReplacement.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

28 tests pass (23 prior + 5 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11677-test-add-unit-tests-for-useNodeReplacement-transfer-edge-cases-34f6d73d3650817aa2ffccdb9fb4a947)
by [Unito](https://www.unito.io)
2026-04-27 10:25:26 -04:00

1160 lines
38 KiB
TypeScript

import { fromAny } from '@total-typescript/shoehorn'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { MissingNodeType } from '@/types/comfy'
import type { NodeReplacement } from './types'
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: {
createNode: vi.fn(),
registered_node_types: {}
}
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: null },
sanitizeNodeName: (name: string) => name.replace(/[&<>"'`=]/g, '')
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: vi.fn()
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
add: vi.fn()
}))
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeWorkflow: {
changeTracker: {
beforeChange: vi.fn(),
afterChange: vi.fn()
}
}
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}))
const { mockRemoveMissingNodesByType } = vi.hoisted(() => ({
mockRemoveMissingNodesByType: vi.fn()
}))
vi.mock('@/platform/nodeReplacement/missingNodesErrorStore', () => ({
useMissingNodesErrorStore: vi.fn(() => ({
removeMissingNodesByType: mockRemoveMissingNodesByType
}))
}))
import { app } from '@/scripts/app'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { useNodeReplacement } from './useNodeReplacement'
function createMockLink(
id: number,
originId: number,
originSlot: number,
targetId: number,
targetSlot: number
) {
return {
id,
origin_id: originId,
origin_slot: originSlot,
target_id: targetId,
target_slot: targetSlot,
type: 'IMAGE'
}
}
function createMockGraph(
nodes: LGraphNode[],
links: ReturnType<typeof createMockLink>[] = []
): LGraph {
const linksMap = new Map(links.map((l) => [l.id, l]))
return fromAny<LGraph, unknown>({
_nodes: nodes,
_nodes_by_id: Object.fromEntries(nodes.map((n) => [n.id, n])),
links: linksMap,
updateExecutionOrder: vi.fn(),
setDirtyCanvas: vi.fn()
})
}
function createPlaceholderNode(
id: number,
type: string,
inputs: { name: string; link: number | null }[] = [],
outputs: { name: string; links: number[] | null }[] = [],
graph?: LGraph
): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
type,
pos: [100, 200],
size: [200, 100],
order: 0,
mode: 0,
flags: {},
has_errors: true,
last_serialization: {
id,
type,
pos: [100, 200],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets_values: []
},
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
graph: graph ?? null,
serialize: vi.fn(() => ({
id,
type,
pos: [100, 200],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets_values: []
}))
})
}
function createNewNode(
inputs: { name: string; link: number | null }[] = [],
outputs: { name: string; links: number[] | null }[] = [],
widgets: { name: string; value: unknown }[] = []
): LGraphNode {
return fromAny<LGraphNode, unknown>({
id: 0,
type: '',
pos: [0, 0],
size: [100, 50],
order: 0,
mode: 0,
flags: {},
has_errors: false,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets: widgets.map((w) => ({ ...w, type: 'combo', options: {} })),
configure: vi.fn(),
serialize: vi.fn()
})
}
function makeMissingNodeType(
type: string,
replacement: NodeReplacement
): MissingNodeType {
return {
type,
isReplaceable: true,
replacement
}
}
describe('useNodeReplacement', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
})
describe('replaceNodesInPlace', () => {
it('should return empty array when no placeholders exist', () => {
const graph = createMockGraph([])
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([])
expect(result).toEqual([])
})
it('should use default mapping when no explicit mapping exists', () => {
const placeholder = createPlaceholderNode(1, 'Load3DAnimation')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('Load3DAnimation', {
new_node_id: 'Load3D',
old_node_id: 'Load3DAnimation',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toEqual(['Load3DAnimation'])
expect(newNode.configure).not.toHaveBeenCalled()
expect(newNode.id).toBe(1)
expect(newNode.has_errors).toBe(false)
})
it('should transfer input connections using input_mapping', () => {
const link = createMockLink(10, 5, 0, 1, 0)
const placeholder = createPlaceholderNode(
1,
'T2IAdapterLoader',
[{ name: 't2i_adapter_name', link: 10 }],
[]
)
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'control_net_name', link: null }],
[]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('T2IAdapterLoader', {
new_node_id: 'ControlNetLoader',
old_node_id: 'T2IAdapterLoader',
old_widget_ids: null,
input_mapping: [
{ new_id: 'control_net_name', old_id: 't2i_adapter_name' }
],
output_mapping: null
})
])
expect(result).toEqual(['T2IAdapterLoader'])
// Link should be updated to point at new node's input
expect(link.target_id).toBe(1)
expect(link.target_slot).toBe(0)
expect(newNode.inputs[0].link).toBe(10)
})
it('should transfer output connections using output_mapping', () => {
const link = createMockLink(20, 1, 0, 5, 0)
const placeholder = createPlaceholderNode(
1,
'ResizeImagesByLongerEdge',
[],
[{ name: 'IMAGE', links: [20] }]
)
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'image', link: null }],
[{ name: 'IMAGE', links: null }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// Output link should be remapped
expect(link.origin_id).toBe(1)
expect(link.origin_slot).toBe(0)
expect(newNode.outputs[0].links).toEqual([20])
})
it('should apply set_value to widget', () => {
const placeholder = createPlaceholderNode(1, 'ImageScaleBy')
placeholder.onRemoved = vi.fn()
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'input', link: null }],
[],
[
{ name: 'resize_type', value: '' },
{ name: 'scale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ImageScaleBy', {
new_node_id: 'ResizeImageMaskNode',
old_node_id: 'ImageScaleBy',
old_widget_ids: ['upscale_method', 'scale_by'],
input_mapping: [
{ new_id: 'input', old_id: 'image' },
{ new_id: 'resize_type', set_value: 'scale by multiplier' },
{ new_id: 'resize_type.multiplier', old_id: 'scale_by' },
{ new_id: 'scale_method', old_id: 'upscale_method' }
],
output_mapping: null
})
])
// set_value should be applied to the widget
expect(newNode.widgets![0].value).toBe('scale by multiplier')
expect(
placeholder.onRemoved,
'call onRemoved on old node'
).toHaveBeenCalledTimes(1)
})
it('should transfer widget values using old_widget_ids', () => {
const placeholder = createPlaceholderNode(1, 'ResizeImagesByLongerEdge')
// Set widget values in serialized data
placeholder.last_serialization!.widgets_values = [512]
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'image', link: null },
{ name: 'largest_size', link: null }
],
[{ name: 'IMAGE', links: null }],
[{ name: 'largest_size', value: 0 }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// Widget value should be transferred: old "longer_edge" (idx 0, value 512) → new "largest_size"
expect(newNode.widgets![0].value).toBe(512)
})
it('should skip replacement when new node type is not registered', () => {
const placeholder = createPlaceholderNode(1, 'UnknownNode')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
vi.mocked(LiteGraph.createNode).mockReturnValue(null)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('UnknownNode', {
new_node_id: 'NonExistentNode',
old_node_id: 'UnknownNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toEqual([])
})
it('should replace multiple different node types at once', () => {
const placeholder1 = createPlaceholderNode(1, 'Load3DAnimation')
const placeholder2 = createPlaceholderNode(
2,
'ConditioningAverage',
[],
[]
)
// sanitizeNodeName strips & from type names (HTML entity chars)
placeholder2.type = 'ConditioningAverage'
const graph = createMockGraph([placeholder1, placeholder2])
placeholder1.graph = graph
placeholder2.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder1, placeholder2])
const newNode1 = createNewNode()
const newNode2 = createNewNode()
vi.mocked(LiteGraph.createNode)
.mockReturnValueOnce(newNode1)
.mockReturnValueOnce(newNode2)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('Load3DAnimation', {
new_node_id: 'Load3D',
old_node_id: 'Load3DAnimation',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}),
makeMissingNodeType('ConditioningAverage&', {
new_node_id: 'ConditioningAverage',
old_node_id: 'ConditioningAverage&',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toHaveLength(2)
expect(result).toContain('Load3DAnimation')
expect(result).toContain('ConditioningAverage&')
})
it('should copy position and identity for mapped replacements', () => {
const link = createMockLink(10, 5, 0, 1, 0)
const placeholder = createPlaceholderNode(
42,
'T2IAdapterLoader',
[{ name: 't2i_adapter_name', link: 10 }],
[]
)
placeholder.pos = [300, 400]
placeholder.size = [250, 150]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'control_net_name', link: null }],
[]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('T2IAdapterLoader', {
new_node_id: 'ControlNetLoader',
old_node_id: 'T2IAdapterLoader',
old_widget_ids: null,
input_mapping: [
{ new_id: 'control_net_name', old_id: 't2i_adapter_name' }
],
output_mapping: null
})
])
expect(newNode.id).toBe(42)
expect(newNode.pos).toEqual([300, 400])
expect(newNode.size).toEqual([250, 150])
expect(graph._nodes[0]).toBe(newNode)
})
it('should transfer all widget values for ImageScaleBy with real workflow data', () => {
const placeholder = createPlaceholderNode(
12,
'ImageScaleBy',
[{ name: 'image', link: 2 }],
[{ name: 'IMAGE', links: [3, 4] }]
)
// Real workflow data: widgets_values: ["lanczos", 2.0]
placeholder.last_serialization!.widgets_values = ['lanczos', 2.0]
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'input', link: null }],
[],
[
{ name: 'resize_type', value: '' },
{ name: 'scale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ImageScaleBy', {
new_node_id: 'ResizeImageMaskNode',
old_node_id: 'ImageScaleBy',
old_widget_ids: ['upscale_method', 'scale_by'],
input_mapping: [
{ new_id: 'input', old_id: 'image' },
{ new_id: 'resize_type', set_value: 'scale by multiplier' },
{ new_id: 'resize_type.multiplier', old_id: 'scale_by' },
{ new_id: 'scale_method', old_id: 'upscale_method' }
],
output_mapping: null
})
])
// set_value should be applied
expect(newNode.widgets![0].value).toBe('scale by multiplier')
// upscale_method (idx 0, value "lanczos") → scale_method widget
expect(newNode.widgets![1].value).toBe('lanczos')
})
it('should transfer widget value for ResizeImagesByLongerEdge with real workflow data', () => {
const link = createMockLink(1, 5, 0, 8, 0)
const placeholder = createPlaceholderNode(
8,
'ResizeImagesByLongerEdge',
[{ name: 'images', link: 1 }],
[{ name: 'IMAGE', links: [2] }]
)
// Real workflow data: widgets_values: [1024]
placeholder.last_serialization!.widgets_values = [1024]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'image', link: null },
{ name: 'largest_size', link: null }
],
[{ name: 'IMAGE', links: null }],
[
{ name: 'largest_size', value: 0 },
{ name: 'upscale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// longer_edge (idx 0, value 1024) → largest_size widget
expect(newNode.widgets![0].value).toBe(1024)
// set_value "lanczos" → upscale_method widget
expect(newNode.widgets![1].value).toBe('lanczos')
})
it('should transfer ConditioningAverage widget value with real workflow data', () => {
const link = createMockLink(4, 7, 0, 13, 0)
// sanitizeNodeName doesn't strip spaces, so placeholder keeps trailing space
const placeholder = createPlaceholderNode(
13,
'ConditioningAverage ',
[
{ name: 'conditioning_to', link: 4 },
{ name: 'conditioning_from', link: null }
],
[{ name: 'CONDITIONING', links: [6] }]
)
placeholder.last_serialization!.widgets_values = [0.75]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'conditioning_to', link: null },
{ name: 'conditioning_from', link: null }
],
[{ name: 'CONDITIONING', links: null }],
[{ name: 'conditioning_average', value: 0 }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ConditioningAverage ', {
new_node_id: 'ConditioningAverage',
old_node_id: 'ConditioningAverage ',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
// Default mapping transfers connections and widget values by name
expect(newNode.id).toBe(13)
expect(newNode.inputs[0].link).toBe(4)
expect(newNode.outputs[0].links).toEqual([6])
expect(newNode.widgets![0].value).toBe(0.75)
})
it('should skip dot-notation input connections but still transfer widget values', () => {
const placeholder = createPlaceholderNode(1, 'ImageBatch')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode([], [])
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('ImageBatch', {
new_node_id: 'BatchImagesNode',
old_node_id: 'ImageBatch',
old_widget_ids: null,
input_mapping: [
{ new_id: 'images.image0', old_id: 'image1' },
{ new_id: 'images.image1', old_id: 'image2' }
],
output_mapping: null
})
])
// Should still succeed (dot-notation skipped gracefully)
expect(result).toEqual(['ImageBatch'])
})
})
describe('placeholder detection predicate', () => {
/**
* replaceNodesInPlace calls collectAllNodes with a predicate.
* These tests capture the predicate by inspecting the mock call
* and verify it matches only nodes whose serialized type is in
* the targetTypes set — regardless of has_errors or registered_node_types.
*/
function capturedPredicate(): (n: LGraphNode) => boolean {
const calls = vi.mocked(collectAllNodes).mock.calls
expect(calls.length).toBeGreaterThan(0)
return calls[calls.length - 1][1] as (n: LGraphNode) => boolean
}
it('should detect placeholder when type is in targetTypes even if has_errors is false', () => {
const placeholder = createPlaceholderNode(1, 'OldNode')
placeholder.has_errors = false
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldNode', {
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
const predicate = capturedPredicate()
expect(predicate(placeholder)).toBe(true)
})
it('should detect placeholder when type is in targetTypes even if type is registered', () => {
// Simulate the pack being reinstalled — type is now registered
;(LiteGraph.registered_node_types as Record<string, unknown>)['OldNode'] =
{}
const placeholder = createPlaceholderNode(1, 'OldNode')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldNode', {
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
const predicate = capturedPredicate()
expect(predicate(placeholder)).toBe(true)
// Cleanup
delete (LiteGraph.registered_node_types as Record<string, unknown>)[
'OldNode'
]
})
it('should exclude nodes whose type is NOT in targetTypes', () => {
const unrelatedNode = createPlaceholderNode(1, 'UnrelatedNode')
const graph = createMockGraph([unrelatedNode])
unrelatedNode.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('SomeOtherNode', {
new_node_id: 'NewNode',
old_node_id: 'SomeOtherNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
const predicate = capturedPredicate()
expect(predicate(unrelatedNode)).toBe(false)
})
it('should exclude nodes without last_serialization', () => {
const freshNode = createPlaceholderNode(1, 'OldNode')
freshNode.last_serialization = fromAny<
LGraphNode['last_serialization'],
unknown
>(undefined)
const graph = createMockGraph([freshNode])
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldNode', {
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
const predicate = capturedPredicate()
expect(predicate(freshNode)).toBe(false)
})
it('should fall back to node.type when last_serialization.type is undefined', () => {
const node = createPlaceholderNode(1, 'FallbackType')
node.last_serialization!.type = fromAny<string, unknown>(undefined)
node.type = 'FallbackType'
const graph = createMockGraph([node])
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('FallbackType', {
new_node_id: 'NewNode',
old_node_id: 'FallbackType',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
const predicate = capturedPredicate()
expect(predicate(node)).toBe(true)
})
it('should match node via sanitized type when last_serialization.type is absent and live type contains HTML special chars', () => {
// Simulates an old serialization format (no last_serialization.type)
// where app.ts has already run sanitizeNodeName on n.type,
// stripping '&' from "OldNode&Special" → "OldNodeSpecial".
// targetTypes still holds the original unsanitized name "OldNode&Special",
// so the predicate must fall back to checking sanitizeNodeName(originalType).
const node = createPlaceholderNode(1, 'OldNodeSpecial')
node.last_serialization!.type = fromAny<string, unknown>(undefined)
// Simulate what sanitizeNodeName does to '&' in the live type
node.type = 'OldNodeSpecial' // '&' already stripped by sanitizeNodeName
const graph = createMockGraph([node])
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
// targetTypes will contain the original name with '&'
makeMissingNodeType('OldNode&Special', {
new_node_id: 'NewNode',
old_node_id: 'OldNode&Special',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
const predicate = capturedPredicate()
// Without the sanitize fallback this would return false.
expect(predicate(node)).toBe(true)
})
})
describe('replaceGroup', () => {
it('calls removeMissingNodesByType with replaced types on success', () => {
const placeholder = createPlaceholderNode(1, 'OldNode')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
const newNode = createNewNode()
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceGroup } = useNodeReplacement()
replaceGroup({
type: 'OldNode',
nodeTypes: [
makeMissingNodeType('OldNode', {
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
]
})
expect(mockRemoveMissingNodesByType).toHaveBeenCalledWith(['OldNode'])
})
it('does not call removeMissingNodesByType when no nodes are replaced', () => {
const graph = createMockGraph([])
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceGroup } = useNodeReplacement()
replaceGroup({
type: 'OldNode',
nodeTypes: [
makeMissingNodeType('OldNode', {
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
]
})
expect(mockRemoveMissingNodesByType).not.toHaveBeenCalled()
})
})
describe('replaceAllGroups', () => {
it('calls removeMissingNodesByType with all successfully replaced types', () => {
const p1 = createPlaceholderNode(1, 'TypeA')
const p2 = createPlaceholderNode(2, 'TypeB')
const graph = createMockGraph([p1, p2])
p1.graph = graph
p2.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([p1, p2])
vi.mocked(LiteGraph.createNode)
.mockReturnValueOnce(createNewNode())
.mockReturnValueOnce(createNewNode())
const { replaceAllGroups } = useNodeReplacement()
replaceAllGroups([
{
type: 'TypeA',
nodeTypes: [
makeMissingNodeType('TypeA', {
new_node_id: 'NewA',
old_node_id: 'TypeA',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
]
},
{
type: 'TypeB',
nodeTypes: [
makeMissingNodeType('TypeB', {
new_node_id: 'NewB',
old_node_id: 'TypeB',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
]
}
])
expect(mockRemoveMissingNodesByType).toHaveBeenCalledWith(
expect.arrayContaining(['TypeA', 'TypeB'])
)
})
it('removes only the types that were actually replaced when some fail', () => {
const p1 = createPlaceholderNode(1, 'TypeA')
const graph = createMockGraph([p1])
p1.graph = graph
Object.assign(app, { rootGraph: graph })
// Only TypeA appears as a placeholder; TypeB has no matching node
vi.mocked(collectAllNodes).mockReturnValue([p1])
vi.mocked(LiteGraph.createNode).mockReturnValueOnce(createNewNode())
const { replaceAllGroups } = useNodeReplacement()
replaceAllGroups([
{
type: 'TypeA',
nodeTypes: [
makeMissingNodeType('TypeA', {
new_node_id: 'NewA',
old_node_id: 'TypeA',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
]
},
{
type: 'TypeB',
nodeTypes: [
makeMissingNodeType('TypeB', {
new_node_id: 'NewB',
old_node_id: 'TypeB',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
]
}
])
// Only TypeA was replaced; TypeB had no matching placeholder
expect(mockRemoveMissingNodesByType).toHaveBeenCalledWith(['TypeA'])
})
})
describe('transfer edge cases', () => {
it('skips input transfer when the old node has no matching input slot', () => {
const placeholder = createPlaceholderNode(1, 'OldType', [
{ name: 'present_input', link: null }
])
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode([{ name: 'new_input', link: null }], [])
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('OldType', {
new_node_id: 'NewType',
old_node_id: 'OldType',
old_widget_ids: null,
// old_id refers to an input that does not exist on the placeholder.
input_mapping: [
{ new_id: 'new_input', old_id: 'missing_input_name' }
],
output_mapping: null
})
])
// Replacement still completes; the missing-old-slot transfer is a no-op.
expect(result).toEqual(['OldType'])
expect(newNode.inputs[0].link).toBeNull()
})
it('does not throw when output_mapping references a new output index that does not exist', () => {
// NOTE: The current source skips transfer silently in this case, leaving
// the link's origin_slot pointing at a now-missing slot on the new node.
// That dangling state is a separate source-level concern; this test only
// pins that the missing-slot branch does not crash the replacement loop.
// Do not extend this test to assert specific link state on the dangling
// path — codifying that as "correct" would block fixing the underlying
// cleanup gap in transferOutputConnections.
const link = createMockLink(20, 1, 0, 5, 0)
const placeholder = createPlaceholderNode(
1,
'OldType',
[],
[{ name: 'IMAGE', links: [20] }]
)
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
// newNode has NO outputs; output_mapping points at index 0 which does not exist.
const newNode = createNewNode([], [])
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
expect(() =>
replaceNodesInPlace([
makeMissingNodeType('OldType', {
new_node_id: 'NewType',
old_node_id: 'OldType',
old_widget_ids: null,
input_mapping: null,
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
).not.toThrow()
})
it('is a no-op when set_value targets a widget that does not exist on the new node', () => {
const placeholder = createPlaceholderNode(1, 'OldType', [
{ name: 'image', link: null }
])
placeholder.onRemoved = vi.fn()
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
// newNode has only one widget; set_value targets a different name.
const newNode = createNewNode(
[{ name: 'image', link: null }],
[],
[{ name: 'resize_method', value: 'bilinear' }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('OldType', {
new_node_id: 'NewType',
old_node_id: 'OldType',
old_widget_ids: null,
input_mapping: [
{ new_id: 'nonexistent_widget', set_value: 'should-not-stick' }
],
output_mapping: null
})
])
expect(result).toEqual(['OldType'])
// Existing widget is unchanged; no new widget was created.
expect(newNode.widgets).toHaveLength(1)
expect(newNode.widgets![0].value).toBe('bilinear')
})
it('skips set_value mapping when the new_id uses dot-notation', () => {
const placeholder = createPlaceholderNode(1, 'OldType')
placeholder.onRemoved = vi.fn()
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'input', link: null }],
[],
[{ name: 'resize_type', value: '' }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldType', {
new_node_id: 'NewType',
old_node_id: 'OldType',
old_widget_ids: null,
input_mapping: [
// Dot-notation target — the set_value should NOT be applied.
{ new_id: 'resize_type.multiplier', set_value: 'dot-notation-skip' }
],
output_mapping: null
})
])
expect(newNode.widgets![0].value).toBe('')
})
it('invokes nodeGraph.onNodeAdded for each replaced node so VueNode data refreshes', () => {
const placeholder = createPlaceholderNode(1, 'OldType')
const graph = createMockGraph([placeholder])
const onNodeAdded = vi.fn()
;(graph as { onNodeAdded?: (n: LGraphNode) => void }).onNodeAdded =
onNodeAdded
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('OldType', {
new_node_id: 'NewType',
old_node_id: 'OldType',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(onNodeAdded).toHaveBeenCalledTimes(1)
expect(onNodeAdded).toHaveBeenCalledWith(newNode)
})
})
})