mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
## Summary Backports #12212 to `cloud/1.43` so paste/drop media uploads clear missing media validation rings through the widget change path. ## Changes - **What**: Cherry-picked `95e616b89404fe2bda5be4dc6194aeac07a65a0b` and adapted the Vue node error E2E conflict for the 1.43 test helper API. - **Dependencies**: None. ## Review Focus Conflict resolution was limited to `browser_tests/tests/vueNodes/nodeStates/error.spec.ts`. The 1.43 branch does not have the newer Vue node error helpers from `main`, so the backport keeps the existing 1.43 tests and adds only the Load Image drop/paste missing-media clearing coverage using local prompt route mocking plus `getNodeLocator()` / `getWidget(0)`. Backport of #12212. ## Screenshots (if applicable) N/A ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12246-backport-cloud-1-43-fix-clear-media-upload-errors-via-widget-change-12212-3606d73d365081cd9074d2bb0c1e9e74) by [Unito](https://www.unito.io)
1034 lines
33 KiB
TypeScript
1034 lines
33 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { fromAny } from '@total-typescript/shoehorn'
|
|
import { setActivePinia } from 'pinia'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
|
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import {
|
|
createTestSubgraph,
|
|
createTestSubgraphNode
|
|
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
|
import {
|
|
LGraphEventMode,
|
|
NodeSlotType
|
|
} from '@/lib/litegraph/src/types/globalEnums'
|
|
import * as missingMediaScan from '@/platform/missingMedia/missingMediaScan'
|
|
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
|
import * as missingModelScan from '@/platform/missingModel/missingModelScan'
|
|
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
|
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
|
import { app } from '@/scripts/app'
|
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
|
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
|
|
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
|
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('Connection error clearing via onConnectionsChange', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
|
})
|
|
|
|
function createGraphWithInput() {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('test')
|
|
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
|
node.addInput('clip', 'CLIP')
|
|
graph.add(node)
|
|
return { graph, node }
|
|
}
|
|
|
|
it('clears simple node error when INPUT is connected', () => {
|
|
const { graph, node } = createGraphWithInput()
|
|
installErrorClearingHooks(graph)
|
|
|
|
const store = useExecutionErrorStore()
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
|
|
|
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
|
|
|
expect(store.lastNodeErrors).toBeNull()
|
|
})
|
|
|
|
it('does not clear errors on disconnection', () => {
|
|
const { graph, node } = createGraphWithInput()
|
|
installErrorClearingHooks(graph)
|
|
|
|
const store = useExecutionErrorStore()
|
|
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
|
|
|
node.onConnectionsChange!(
|
|
NodeSlotType.INPUT,
|
|
0,
|
|
false,
|
|
null,
|
|
node.inputs[0]
|
|
)
|
|
|
|
expect(store.lastNodeErrors).not.toBeNull()
|
|
})
|
|
|
|
it('does not clear errors on OUTPUT connection', () => {
|
|
const { graph, node } = createGraphWithInput()
|
|
node.addOutput('out', 'CLIP')
|
|
installErrorClearingHooks(graph)
|
|
|
|
const store = useExecutionErrorStore()
|
|
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
|
|
|
node.onConnectionsChange!(
|
|
NodeSlotType.OUTPUT,
|
|
0,
|
|
true,
|
|
null,
|
|
node.outputs[0]
|
|
)
|
|
|
|
expect(store.lastNodeErrors).not.toBeNull()
|
|
})
|
|
|
|
it('clears errors for pure input slots without widget property', () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('test')
|
|
node.addInput('model', 'MODEL')
|
|
graph.add(node)
|
|
installErrorClearingHooks(graph)
|
|
|
|
const store = useExecutionErrorStore()
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
seedRequiredInputMissingNodeError(store, String(node.id), 'model')
|
|
|
|
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
|
|
|
expect(store.lastNodeErrors).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Widget change error clearing via onWidgetChanged', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
|
})
|
|
|
|
it('clears simple error when widget value changes to valid range', () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('test')
|
|
node.addWidget('number', 'steps', 20, () => undefined, {
|
|
min: 1,
|
|
max: 100
|
|
})
|
|
graph.add(node)
|
|
installErrorClearingHooks(graph)
|
|
|
|
const store = useExecutionErrorStore()
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
store.lastNodeErrors = {
|
|
[String(node.id)]: {
|
|
errors: [
|
|
{
|
|
type: 'value_bigger_than_max',
|
|
message: 'Too big',
|
|
details: '',
|
|
extra_info: { input_name: 'steps' }
|
|
}
|
|
],
|
|
dependent_outputs: [],
|
|
class_type: 'TestNode'
|
|
}
|
|
}
|
|
|
|
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
|
|
|
|
expect(store.lastNodeErrors).toBeNull()
|
|
})
|
|
|
|
it('retains error when widget value is still out of range', () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('test')
|
|
node.addWidget('number', 'steps', 20, () => undefined, {
|
|
min: 1,
|
|
max: 100
|
|
})
|
|
graph.add(node)
|
|
installErrorClearingHooks(graph)
|
|
|
|
const store = useExecutionErrorStore()
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
store.lastNodeErrors = {
|
|
[String(node.id)]: {
|
|
errors: [
|
|
{
|
|
type: 'value_bigger_than_max',
|
|
message: 'Too big',
|
|
details: '',
|
|
extra_info: { input_name: 'steps' }
|
|
}
|
|
],
|
|
dependent_outputs: [],
|
|
class_type: 'TestNode'
|
|
}
|
|
}
|
|
|
|
node.onWidgetChanged!.call(node, 'steps', 150, 20, node.widgets![0])
|
|
|
|
expect(store.lastNodeErrors).not.toBeNull()
|
|
})
|
|
|
|
it('does not clear errors when rootGraph is unavailable', () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('test')
|
|
node.addWidget('number', 'steps', 20, () => undefined, {})
|
|
graph.add(node)
|
|
installErrorClearingHooks(graph)
|
|
|
|
const store = useExecutionErrorStore()
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(
|
|
fromAny<LGraph, unknown>(undefined)
|
|
)
|
|
store.lastNodeErrors = {
|
|
[String(node.id)]: {
|
|
errors: [
|
|
{
|
|
type: 'value_bigger_than_max',
|
|
message: 'Too big',
|
|
details: '',
|
|
extra_info: { input_name: 'steps' }
|
|
}
|
|
],
|
|
dependent_outputs: [],
|
|
class_type: 'TestNode'
|
|
}
|
|
}
|
|
|
|
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
|
|
|
|
expect(store.lastNodeErrors).not.toBeNull()
|
|
})
|
|
|
|
it('clears missing media when an upload emits onWidgetChanged', () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('LoadImage')
|
|
node.type = 'LoadImage'
|
|
const widget = node.addWidget(
|
|
'combo',
|
|
'image',
|
|
'missing.png',
|
|
() => undefined,
|
|
{ values: [] }
|
|
)
|
|
graph.add(node)
|
|
installErrorClearingHooks(graph)
|
|
|
|
const store = useExecutionErrorStore()
|
|
const mediaStore = useMissingMediaStore()
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
|
|
mediaStore.setMissingMedia([
|
|
{
|
|
nodeId: String(node.id),
|
|
nodeType: 'LoadImage',
|
|
widgetName: 'image',
|
|
mediaType: 'image',
|
|
name: 'missing.png',
|
|
isMissing: true
|
|
} satisfies MissingMediaCandidate
|
|
])
|
|
|
|
node.onWidgetChanged!.call(
|
|
node,
|
|
'image',
|
|
'uploaded.png',
|
|
'missing.png',
|
|
widget
|
|
)
|
|
|
|
expect(store.lastNodeErrors).toBeNull()
|
|
expect(mediaStore.missingMediaCandidates).toBeNull()
|
|
})
|
|
|
|
it('uses interior node execution ID for promoted widget error clearing', () => {
|
|
const subgraph = createTestSubgraph({
|
|
inputs: [{ name: 'ckpt_input', type: '*' }]
|
|
})
|
|
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
|
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
|
interiorNode.addWidget(
|
|
'combo',
|
|
'ckpt_name',
|
|
'model.safetensors',
|
|
() => undefined,
|
|
{ values: ['model.safetensors'] }
|
|
)
|
|
interiorInput.widget = { name: 'ckpt_name' }
|
|
subgraph.add(interiorNode)
|
|
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
|
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
|
subgraphNode._internalConfigureAfterSlots()
|
|
const graph = subgraphNode.graph as LGraph
|
|
graph.add(subgraphNode)
|
|
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
installErrorClearingHooks(graph)
|
|
|
|
const store = useExecutionErrorStore()
|
|
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
|
|
|
const promotedWidget = subgraphNode.widgets?.find(
|
|
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'ckpt_name'
|
|
)
|
|
expect(promotedWidget).toBeDefined()
|
|
|
|
// PromotedWidgetView.name returns displayName ("ckpt_input"), which is
|
|
// passed as errorInputName to clearSimpleNodeErrors. Seed the error
|
|
// with that name so the slot-name filter matches.
|
|
seedRequiredInputMissingNodeError(
|
|
store,
|
|
interiorExecId,
|
|
promotedWidget!.name
|
|
)
|
|
|
|
subgraphNode.onWidgetChanged!.call(
|
|
subgraphNode,
|
|
'ckpt_name',
|
|
'other_model.safetensors',
|
|
'model.safetensors',
|
|
promotedWidget!
|
|
)
|
|
|
|
expect(store.lastNodeErrors).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('installErrorClearingHooks lifecycle', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
|
})
|
|
|
|
it('propagates hooks to nodes added after installation', () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('test')
|
|
node.addInput('value', 'INT')
|
|
graph.add(node)
|
|
installErrorClearingHooks(graph)
|
|
|
|
// Add a new node after hooks are installed
|
|
const lateNode = new LGraphNode('late')
|
|
lateNode.addInput('value', 'INT')
|
|
graph.add(lateNode)
|
|
|
|
// The late-added node should have error-clearing hooks
|
|
expect(lateNode.onConnectionsChange).toBeDefined()
|
|
expect(lateNode.onWidgetChanged).toBeDefined()
|
|
|
|
// Verify the hooks actually work
|
|
const store = useExecutionErrorStore()
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
seedRequiredInputMissingNodeError(store, String(lateNode.id), 'value')
|
|
|
|
lateNode.onConnectionsChange!(
|
|
NodeSlotType.INPUT,
|
|
0,
|
|
true,
|
|
null,
|
|
lateNode.inputs[0]
|
|
)
|
|
|
|
expect(store.lastNodeErrors).toBeNull()
|
|
})
|
|
|
|
it('restores original onNodeAdded when cleanup is called', () => {
|
|
const graph = new LGraph()
|
|
const originalHook = vi.fn()
|
|
graph.onNodeAdded = originalHook
|
|
|
|
const cleanup = installErrorClearingHooks(graph)
|
|
expect(graph.onNodeAdded).not.toBe(originalHook)
|
|
|
|
cleanup()
|
|
expect(graph.onNodeAdded).toBe(originalHook)
|
|
})
|
|
|
|
it('restores original node callbacks when a node is removed', () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('test')
|
|
node.addInput('clip', 'CLIP')
|
|
node.addWidget('number', 'steps', 20, () => undefined, {})
|
|
const originalOnConnectionsChange = vi.fn()
|
|
const originalOnWidgetChanged = vi.fn()
|
|
node.onConnectionsChange = originalOnConnectionsChange
|
|
node.onWidgetChanged = originalOnWidgetChanged
|
|
graph.add(node)
|
|
|
|
installErrorClearingHooks(graph)
|
|
|
|
// Callbacks should be chained (not the originals)
|
|
expect(node.onConnectionsChange).not.toBe(originalOnConnectionsChange)
|
|
expect(node.onWidgetChanged).not.toBe(originalOnWidgetChanged)
|
|
|
|
// Simulate node removal via the graph hook
|
|
graph.onNodeRemoved!(node)
|
|
|
|
// Original callbacks should be restored
|
|
expect(node.onConnectionsChange).toBe(originalOnConnectionsChange)
|
|
expect(node.onWidgetChanged).toBe(originalOnWidgetChanged)
|
|
})
|
|
|
|
it('does not double-wrap callbacks when installErrorClearingHooks is called twice', () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('test')
|
|
node.addInput('clip', 'CLIP')
|
|
graph.add(node)
|
|
|
|
installErrorClearingHooks(graph)
|
|
const chainedAfterFirst = node.onConnectionsChange
|
|
|
|
// Install again on the same graph — should be a no-op for existing nodes
|
|
installErrorClearingHooks(graph)
|
|
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
|
|
})
|
|
|
|
it('scans added-node missing models after widget values are restored', async () => {
|
|
const graph = new LGraph()
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
installErrorClearingHooks(graph)
|
|
|
|
const node = new LGraphNode('CheckpointLoaderSimple')
|
|
node.type = 'CheckpointLoaderSimple'
|
|
const widget = node.addWidget('combo', 'ckpt_name', '', () => undefined, {
|
|
values: []
|
|
})
|
|
|
|
graph.add(node)
|
|
widget.value = 'fake_model.safetensors'
|
|
|
|
await Promise.resolve()
|
|
|
|
expect(useMissingModelStore().missingModelCandidates).toEqual([
|
|
expect.objectContaining({ name: 'fake_model.safetensors' })
|
|
])
|
|
})
|
|
|
|
it('scans added-node missing models before the deferred media scan', async () => {
|
|
const graph = new LGraph()
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
const modelScan = vi
|
|
.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
|
.mockImplementation((_rootGraph, node) => [
|
|
{
|
|
nodeId: String(node.id),
|
|
nodeType: node.type,
|
|
widgetName: 'ckpt_name',
|
|
isAssetSupported: false,
|
|
name: 'fake_model.safetensors',
|
|
directory: 'checkpoints',
|
|
isMissing: true
|
|
} satisfies MissingModelCandidate
|
|
])
|
|
const mediaScan = vi
|
|
.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
|
|
.mockReturnValue([])
|
|
installErrorClearingHooks(graph)
|
|
|
|
const node = new LGraphNode('CheckpointLoaderSimple')
|
|
node.type = 'CheckpointLoaderSimple'
|
|
graph.add(node)
|
|
|
|
await Promise.resolve()
|
|
|
|
expect(modelScan).toHaveBeenCalledOnce()
|
|
expect(useMissingModelStore().missingModelCandidates).toEqual([
|
|
expect.objectContaining({ name: 'fake_model.safetensors' })
|
|
])
|
|
expect(mediaScan).not.toHaveBeenCalled()
|
|
|
|
await Promise.resolve()
|
|
|
|
expect(mediaScan).toHaveBeenCalledTimes(1)
|
|
expect(modelScan.mock.invocationCallOrder[0]).toBeLessThan(
|
|
mediaScan.mock.invocationCallOrder[0]
|
|
)
|
|
})
|
|
|
|
it('does not surface added-node missing media when upload state is marked between deferred scans', async () => {
|
|
const graph = new LGraph()
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
|
const mediaScan = vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
|
|
installErrorClearingHooks(graph)
|
|
|
|
const node = new LGraphNode('LoadVideo')
|
|
node.type = 'LoadVideo'
|
|
node.addWidget('combo', 'file', 'uploading.mp4', () => undefined, {
|
|
values: []
|
|
})
|
|
|
|
graph.add(node)
|
|
await Promise.resolve()
|
|
node.isUploading = true
|
|
await Promise.resolve()
|
|
|
|
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
|
expect(mediaScan).toHaveBeenCalledOnce()
|
|
})
|
|
})
|
|
|
|
describe('onNodeRemoved clears missing asset errors by execution ID', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
|
})
|
|
|
|
it('removes root-level node missing model error using its local id', () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('CheckpointLoaderSimple')
|
|
graph.add(node)
|
|
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
installErrorClearingHooks(graph)
|
|
|
|
const modelStore = useMissingModelStore()
|
|
modelStore.setMissingModels([
|
|
fromAny<
|
|
Parameters<typeof modelStore.setMissingModels>[0][number],
|
|
unknown
|
|
>({
|
|
nodeId: String(node.id),
|
|
nodeType: 'CheckpointLoaderSimple',
|
|
widgetName: 'ckpt_name',
|
|
isAssetSupported: false,
|
|
name: 'model.safetensors',
|
|
isMissing: true
|
|
})
|
|
])
|
|
|
|
graph.remove(node)
|
|
|
|
expect(modelStore.missingModelCandidates).toBeNull()
|
|
})
|
|
|
|
it('removes subgraph interior node missing model error using parentId:nodeId', () => {
|
|
// Regression: node.graph is nulled before onNodeRemoved fires, so
|
|
// getExecutionIdByNode returned null and removal fell back to the
|
|
// local node id. Errors stored under "parentId:nodeId" were never
|
|
// removed for subgraph interior nodes.
|
|
const subgraph = createTestSubgraph()
|
|
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
|
subgraph.add(interiorNode)
|
|
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
|
const rootGraph = subgraphNode.graph as LGraph
|
|
rootGraph.add(subgraphNode)
|
|
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
|
// Hooks are installed on whichever graph is currently active in
|
|
// the canvas; when the user is inside the subgraph, that is the
|
|
// graph whose onNodeRemoved fires for interior deletions.
|
|
installErrorClearingHooks(subgraph)
|
|
|
|
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
|
const modelStore = useMissingModelStore()
|
|
modelStore.setMissingModels([
|
|
fromAny<
|
|
Parameters<typeof modelStore.setMissingModels>[0][number],
|
|
unknown
|
|
>({
|
|
nodeId: interiorExecId,
|
|
nodeType: 'CheckpointLoaderSimple',
|
|
widgetName: 'ckpt_name',
|
|
isAssetSupported: false,
|
|
name: 'model.safetensors',
|
|
isMissing: true
|
|
})
|
|
])
|
|
|
|
subgraph.remove(interiorNode)
|
|
|
|
expect(modelStore.missingModelCandidates).toBeNull()
|
|
})
|
|
|
|
it('removes subgraph interior node missing media and missing node errors', () => {
|
|
const subgraph = createTestSubgraph()
|
|
const interiorNode = new LGraphNode('LoadImage')
|
|
subgraph.add(interiorNode)
|
|
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
|
const rootGraph = subgraphNode.graph as LGraph
|
|
rootGraph.add(subgraphNode)
|
|
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
|
installErrorClearingHooks(subgraph)
|
|
|
|
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
|
|
|
const mediaStore = useMissingMediaStore()
|
|
mediaStore.setMissingMedia([
|
|
fromAny<
|
|
Parameters<typeof mediaStore.setMissingMedia>[0][number],
|
|
unknown
|
|
>({
|
|
nodeId: interiorExecId,
|
|
nodeType: 'LoadImage',
|
|
widgetName: 'image',
|
|
mediaType: 'image',
|
|
name: 'cat.png',
|
|
isMissing: true
|
|
})
|
|
])
|
|
|
|
const nodesStore = useMissingNodesErrorStore()
|
|
nodesStore.surfaceMissingNodes([
|
|
{
|
|
type: 'LoadImage',
|
|
nodeId: interiorExecId,
|
|
cnrId: undefined,
|
|
isReplaceable: false,
|
|
replacement: undefined
|
|
}
|
|
])
|
|
|
|
subgraph.remove(interiorNode)
|
|
|
|
expect(mediaStore.missingMediaCandidates).toBeNull()
|
|
expect(nodesStore.missingNodesError).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('realtime scan verifies pending cloud candidates', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
|
})
|
|
|
|
it('un-bypass path surfaces pending model candidates after verification', async () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('CheckpointLoaderSimple')
|
|
graph.add(node)
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
|
|
// Cloud mode returns candidates with isMissing: undefined until
|
|
// verifyAssetSupportedCandidates resolves them against the assets store.
|
|
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
|
{
|
|
nodeId: String(node.id),
|
|
nodeType: 'CheckpointLoaderSimple',
|
|
widgetName: 'ckpt_name',
|
|
isAssetSupported: true,
|
|
name: 'cloud_model.safetensors',
|
|
isMissing: undefined
|
|
}
|
|
])
|
|
const verifySpy = vi
|
|
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
|
|
.mockImplementation(async (candidates) => {
|
|
for (const c of candidates) c.isMissing = true
|
|
})
|
|
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
|
|
|
installErrorClearingHooks(graph)
|
|
|
|
// Simulate un-bypass (BYPASS → NEVER_BY_USER is not active; use 0 = active)
|
|
node.mode = LGraphEventMode.ALWAYS
|
|
graph.onTrigger?.({
|
|
type: 'node:property:changed',
|
|
nodeId: node.id,
|
|
property: 'mode',
|
|
oldValue: LGraphEventMode.BYPASS,
|
|
newValue: LGraphEventMode.ALWAYS
|
|
})
|
|
|
|
await vi.waitFor(() => {
|
|
expect(verifySpy).toHaveBeenCalledOnce()
|
|
})
|
|
await vi.waitFor(() => {
|
|
const store = useMissingModelStore()
|
|
expect(store.missingModelCandidates).toHaveLength(1)
|
|
expect(store.missingModelCandidates![0].name).toBe(
|
|
'cloud_model.safetensors'
|
|
)
|
|
})
|
|
})
|
|
|
|
it('un-bypass path surfaces pending media candidates after verification', async () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('LoadImage')
|
|
graph.add(node)
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
|
|
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
|
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
|
|
{
|
|
nodeId: String(node.id),
|
|
nodeType: 'LoadImage',
|
|
widgetName: 'image',
|
|
mediaType: 'image',
|
|
name: 'cloud_image.png',
|
|
isMissing: undefined
|
|
}
|
|
])
|
|
const verifySpy = vi
|
|
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
|
.mockImplementation(async (candidates) => {
|
|
for (const c of candidates) c.isMissing = true
|
|
})
|
|
|
|
installErrorClearingHooks(graph)
|
|
|
|
node.mode = LGraphEventMode.ALWAYS
|
|
graph.onTrigger?.({
|
|
type: 'node:property:changed',
|
|
nodeId: node.id,
|
|
property: 'mode',
|
|
oldValue: LGraphEventMode.BYPASS,
|
|
newValue: LGraphEventMode.ALWAYS
|
|
})
|
|
|
|
await vi.waitFor(() => {
|
|
expect(verifySpy).toHaveBeenCalledOnce()
|
|
})
|
|
await vi.waitFor(() => {
|
|
const store = useMissingMediaStore()
|
|
expect(store.missingMediaCandidates).toHaveLength(1)
|
|
expect(store.missingMediaCandidates![0].name).toBe('cloud_image.png')
|
|
})
|
|
})
|
|
|
|
it('does not add candidates that remain confirmed-present after verification', async () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('CheckpointLoaderSimple')
|
|
graph.add(node)
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
|
|
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
|
{
|
|
nodeId: String(node.id),
|
|
nodeType: 'CheckpointLoaderSimple',
|
|
widgetName: 'ckpt_name',
|
|
isAssetSupported: true,
|
|
name: 'present.safetensors',
|
|
isMissing: undefined
|
|
}
|
|
])
|
|
vi.spyOn(
|
|
missingModelScan,
|
|
'verifyAssetSupportedCandidates'
|
|
).mockImplementation(async (candidates) => {
|
|
for (const c of candidates) c.isMissing = false
|
|
})
|
|
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
|
|
|
installErrorClearingHooks(graph)
|
|
|
|
node.mode = LGraphEventMode.ALWAYS
|
|
graph.onTrigger?.({
|
|
type: 'node:property:changed',
|
|
nodeId: node.id,
|
|
property: 'mode',
|
|
oldValue: LGraphEventMode.BYPASS,
|
|
newValue: LGraphEventMode.ALWAYS
|
|
})
|
|
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('realtime verification staleness guards', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
|
})
|
|
|
|
it('skips adding verified model when node was bypassed before verification resolved', async () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('CheckpointLoaderSimple')
|
|
graph.add(node)
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
|
|
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
|
{
|
|
nodeId: String(node.id),
|
|
nodeType: 'CheckpointLoaderSimple',
|
|
widgetName: 'ckpt_name',
|
|
isAssetSupported: true,
|
|
name: 'stale_model.safetensors',
|
|
isMissing: undefined
|
|
}
|
|
])
|
|
let resolveVerify: (() => void) | undefined
|
|
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
|
const verifySpy = vi
|
|
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
|
|
.mockImplementation(async (candidates) => {
|
|
await verifyPromise
|
|
for (const c of candidates) c.isMissing = true
|
|
})
|
|
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
|
|
|
installErrorClearingHooks(graph)
|
|
|
|
// Un-bypass: kicks off verification (still pending)
|
|
node.mode = LGraphEventMode.ALWAYS
|
|
graph.onTrigger?.({
|
|
type: 'node:property:changed',
|
|
nodeId: node.id,
|
|
property: 'mode',
|
|
oldValue: LGraphEventMode.BYPASS,
|
|
newValue: LGraphEventMode.ALWAYS
|
|
})
|
|
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
|
|
|
// Bypass again before verification resolves
|
|
node.mode = LGraphEventMode.BYPASS
|
|
|
|
// Verification now resolves with isMissing: true, but staleness
|
|
// check must drop the add because node is currently bypassed.
|
|
resolveVerify!()
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
|
})
|
|
|
|
it('skips adding verified media when node is deleted before verification resolved', async () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('LoadImage')
|
|
graph.add(node)
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
|
|
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
|
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
|
|
{
|
|
nodeId: String(node.id),
|
|
nodeType: 'LoadImage',
|
|
widgetName: 'image',
|
|
mediaType: 'image',
|
|
name: 'deleted_image.png',
|
|
isMissing: undefined
|
|
}
|
|
])
|
|
let resolveVerify: (() => void) | undefined
|
|
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
|
const verifySpy = vi
|
|
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
|
.mockImplementation(async (candidates) => {
|
|
await verifyPromise
|
|
for (const c of candidates) c.isMissing = true
|
|
})
|
|
|
|
installErrorClearingHooks(graph)
|
|
|
|
node.mode = LGraphEventMode.ALWAYS
|
|
graph.onTrigger?.({
|
|
type: 'node:property:changed',
|
|
nodeId: node.id,
|
|
property: 'mode',
|
|
oldValue: LGraphEventMode.BYPASS,
|
|
newValue: LGraphEventMode.ALWAYS
|
|
})
|
|
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
|
|
|
// Delete the node before verification completes
|
|
graph.remove(node)
|
|
|
|
resolveVerify!()
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
|
})
|
|
|
|
it('skips adding verified model when rootGraph switched before verification resolved', async () => {
|
|
// Workflow A has a pending candidate on node id=1. A is replaced
|
|
// by workflow B (fresh LGraph, potentially has a node with the
|
|
// same id). Late verification from A must not leak into B.
|
|
const graphA = new LGraph()
|
|
const nodeA = new LGraphNode('CheckpointLoaderSimple')
|
|
graphA.add(nodeA)
|
|
const rootSpy = vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graphA)
|
|
|
|
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
|
{
|
|
nodeId: String(nodeA.id),
|
|
nodeType: 'CheckpointLoaderSimple',
|
|
widgetName: 'ckpt_name',
|
|
isAssetSupported: true,
|
|
name: 'stale_from_A.safetensors',
|
|
isMissing: undefined
|
|
}
|
|
])
|
|
let resolveVerify: (() => void) | undefined
|
|
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
|
const verifySpy = vi
|
|
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
|
|
.mockImplementation(async (candidates) => {
|
|
await verifyPromise
|
|
for (const c of candidates) c.isMissing = true
|
|
})
|
|
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
|
|
|
installErrorClearingHooks(graphA)
|
|
|
|
nodeA.mode = LGraphEventMode.ALWAYS
|
|
graphA.onTrigger?.({
|
|
type: 'node:property:changed',
|
|
nodeId: nodeA.id,
|
|
property: 'mode',
|
|
oldValue: LGraphEventMode.BYPASS,
|
|
newValue: LGraphEventMode.ALWAYS
|
|
})
|
|
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
|
|
|
// Workflow swap: app.rootGraph now points at graphB.
|
|
const graphB = new LGraph()
|
|
const nodeB = new LGraphNode('CheckpointLoaderSimple')
|
|
graphB.add(nodeB)
|
|
rootSpy.mockReturnValue(graphB)
|
|
|
|
resolveVerify!()
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
// A's verification finished but rootGraph is now B — the late
|
|
// result must not be added to the store.
|
|
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('scan skips interior of bypassed subgraph containers', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
|
})
|
|
|
|
it('does not surface interior missing model when entering a bypassed subgraph', async () => {
|
|
// Repro: root has a bypassed subgraph container, interior node is
|
|
// itself active. useGraphNodeManager replays `onNodeAdded` for each
|
|
// interior node on subgraph entry, which previously reached
|
|
// scanSingleNodeErrors without an ancestor check and resurfaced the
|
|
// error that the initial pipeline post-filter had correctly dropped.
|
|
const subgraph = createTestSubgraph()
|
|
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
|
subgraph.add(interiorNode)
|
|
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
|
subgraphNode.mode = LGraphEventMode.BYPASS
|
|
const rootGraph = subgraphNode.graph as LGraph
|
|
rootGraph.add(subgraphNode)
|
|
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
|
// Any scanner output would surface the error if the ancestor guard
|
|
// didn't short-circuit first — return a concrete missing candidate.
|
|
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
|
{
|
|
nodeId: `${subgraphNode.id}:${interiorNode.id}`,
|
|
nodeType: 'CheckpointLoaderSimple',
|
|
widgetName: 'ckpt_name',
|
|
isAssetSupported: false,
|
|
name: 'fake.safetensors',
|
|
isMissing: true
|
|
}
|
|
])
|
|
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
|
|
|
installErrorClearingHooks(subgraph)
|
|
|
|
// Simulate useGraphNodeManager replaying onNodeAdded for existing
|
|
// interior nodes after Vue node manager init on subgraph entry.
|
|
subgraph.onNodeAdded?.(interiorNode)
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
|
})
|
|
|
|
it('skips nested subgraph containers during parent subgraph replay scan', async () => {
|
|
const rootGraph = new LGraph()
|
|
const outerSubgraph = createTestSubgraph({ rootGraph })
|
|
const innerSubgraph = createTestSubgraph({ rootGraph })
|
|
const leafNode = new LGraphNode('UNETLoader')
|
|
innerSubgraph.add(leafNode)
|
|
|
|
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
|
parentGraph: outerSubgraph,
|
|
id: 76
|
|
})
|
|
outerSubgraph.add(innerSubgraphNode)
|
|
|
|
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, {
|
|
parentGraph: rootGraph,
|
|
id: 205
|
|
})
|
|
rootGraph.add(outerSubgraphNode)
|
|
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
|
const modelScanSpy = vi
|
|
.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
|
.mockReturnValue([])
|
|
const mediaScanSpy = vi
|
|
.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
|
|
.mockReturnValue([])
|
|
|
|
installErrorClearingHooks(rootGraph)
|
|
|
|
rootGraph.onNodeAdded?.(outerSubgraphNode)
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(modelScanSpy).toHaveBeenCalledWith(
|
|
rootGraph,
|
|
leafNode,
|
|
expect.any(Function),
|
|
expect.any(Function)
|
|
)
|
|
expect(modelScanSpy).not.toHaveBeenCalledWith(
|
|
rootGraph,
|
|
innerSubgraphNode,
|
|
expect.any(Function),
|
|
expect.any(Function)
|
|
)
|
|
expect(mediaScanSpy).toHaveBeenCalledWith(rootGraph, leafNode, false)
|
|
expect(mediaScanSpy).not.toHaveBeenCalledWith(
|
|
rootGraph,
|
|
innerSubgraphNode,
|
|
false
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('clearWidgetRelatedErrors parameter routing', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
|
})
|
|
|
|
it('passes widgetName (not errorInputName) for model lookup', () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('test')
|
|
const widget = node.addWidget('number', 'steps', 42, () => undefined, {
|
|
min: 0,
|
|
max: 100
|
|
})
|
|
graph.add(node)
|
|
installErrorClearingHooks(graph)
|
|
|
|
const store = useExecutionErrorStore()
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
const clearSpy = vi.spyOn(store, 'clearWidgetRelatedErrors')
|
|
|
|
node.onWidgetChanged!.call(node, 'steps', 42, 0, widget)
|
|
|
|
expect(clearSpy).toHaveBeenCalledWith(
|
|
String(node.id),
|
|
'steps',
|
|
'steps',
|
|
42,
|
|
{ min: 0, max: 100 }
|
|
)
|
|
|
|
clearSpy.mockRestore()
|
|
})
|
|
})
|