mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-25 07:05:26 +00:00
Compare commits
2 Commits
proxy-widg
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16233b2621 | ||
|
|
470726d122 |
@@ -11,6 +11,8 @@ import {
|
|||||||
createTestSubgraphNode
|
createTestSubgraphNode
|
||||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||||
|
import { app } from '@/scripts/app'
|
||||||
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||||
import { usePromotionStore } from '@/stores/promotionStore'
|
import { usePromotionStore } from '@/stores/promotionStore'
|
||||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||||
|
|
||||||
@@ -316,3 +318,124 @@ describe('Nested promoted widget mapping', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Connection error clearing via node:slot-links:changed', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||||
|
})
|
||||||
|
|
||||||
|
function createGraphWithInput() {
|
||||||
|
const graph = new LGraph()
|
||||||
|
const node = new LGraphNode('test')
|
||||||
|
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||||
|
const input = node.addInput('clip', 'CLIP')
|
||||||
|
input.widget = { name: 'clip' }
|
||||||
|
graph.add(node)
|
||||||
|
return { graph, node }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('calls clearSimpleNodeErrors on INPUT connection', () => {
|
||||||
|
const { graph, node } = createGraphWithInput()
|
||||||
|
useGraphNodeManager(graph)
|
||||||
|
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
const clearSpy = vi.spyOn(store, 'clearSimpleNodeErrors')
|
||||||
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||||
|
|
||||||
|
graph.trigger('node:slot-links:changed', {
|
||||||
|
nodeId: node.id,
|
||||||
|
slotType: NodeSlotType.INPUT,
|
||||||
|
slotIndex: 0,
|
||||||
|
connected: true,
|
||||||
|
linkId: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(clearSpy).toHaveBeenCalledWith(String(node.id), 'clip')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not clear errors on disconnection', () => {
|
||||||
|
const { graph, node } = createGraphWithInput()
|
||||||
|
useGraphNodeManager(graph)
|
||||||
|
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
const clearSpy = vi.spyOn(store, 'clearSimpleNodeErrors')
|
||||||
|
|
||||||
|
graph.trigger('node:slot-links:changed', {
|
||||||
|
nodeId: node.id,
|
||||||
|
slotType: NodeSlotType.INPUT,
|
||||||
|
slotIndex: 0,
|
||||||
|
connected: false,
|
||||||
|
linkId: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(clearSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not clear errors on OUTPUT connection', () => {
|
||||||
|
const { graph, node } = createGraphWithInput()
|
||||||
|
node.addOutput('out', 'CLIP')
|
||||||
|
useGraphNodeManager(graph)
|
||||||
|
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
const clearSpy = vi.spyOn(store, 'clearSimpleNodeErrors')
|
||||||
|
|
||||||
|
graph.trigger('node:slot-links:changed', {
|
||||||
|
nodeId: node.id,
|
||||||
|
slotType: NodeSlotType.OUTPUT,
|
||||||
|
slotIndex: 0,
|
||||||
|
connected: true,
|
||||||
|
linkId: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(clearSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Widget change error clearing via onWidgetChanged', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls clearWidgetRelatedErrors on widget change', () => {
|
||||||
|
const graph = new LGraph()
|
||||||
|
const node = new LGraphNode('test')
|
||||||
|
node.addWidget('number', 'steps', 20, () => undefined, {
|
||||||
|
min: 1,
|
||||||
|
max: 100
|
||||||
|
})
|
||||||
|
graph.add(node)
|
||||||
|
useGraphNodeManager(graph)
|
||||||
|
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
const clearSpy = vi.spyOn(store, 'clearWidgetRelatedErrors')
|
||||||
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||||
|
|
||||||
|
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
|
||||||
|
|
||||||
|
expect(clearSpy).toHaveBeenCalledWith(
|
||||||
|
String(node.id),
|
||||||
|
'steps',
|
||||||
|
'steps',
|
||||||
|
50,
|
||||||
|
{ min: 1, max: 100 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing when rootGraph is unavailable', () => {
|
||||||
|
const graph = new LGraph()
|
||||||
|
const node = new LGraphNode('test')
|
||||||
|
node.addWidget('number', 'steps', 20, () => undefined, {})
|
||||||
|
graph.add(node)
|
||||||
|
useGraphNodeManager(graph)
|
||||||
|
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
const clearSpy = vi.spyOn(store, 'clearWidgetRelatedErrors')
|
||||||
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(
|
||||||
|
undefined as unknown as LGraph
|
||||||
|
)
|
||||||
|
|
||||||
|
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
|
||||||
|
|
||||||
|
expect(clearSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import type {
|
|||||||
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||||
|
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
|
||||||
|
|
||||||
export interface WidgetSlotMetadata {
|
export interface WidgetSlotMetadata {
|
||||||
index: number
|
index: number
|
||||||
@@ -560,6 +562,25 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
|||||||
initializeVueNodeLayout()
|
initializeVueNodeLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear simple validation errors and missing model state when a widget
|
||||||
|
// value changes in legacy canvas mode. Vue nodes handle this in
|
||||||
|
// NodeWidgets.vue updateHandler instead.
|
||||||
|
node.onWidgetChanged = useChainCallback(
|
||||||
|
node.onWidgetChanged,
|
||||||
|
function (_name, newValue, _oldValue, widget) {
|
||||||
|
if (!app.rootGraph) return
|
||||||
|
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||||
|
if (!execId) return
|
||||||
|
useExecutionErrorStore().clearWidgetRelatedErrors(
|
||||||
|
execId,
|
||||||
|
widget.name,
|
||||||
|
widget.name,
|
||||||
|
newValue,
|
||||||
|
{ min: widget.options?.min, max: widget.options?.max }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Call original callback if provided
|
// Call original callback if provided
|
||||||
if (originalCallback) {
|
if (originalCallback) {
|
||||||
void originalCallback(node)
|
void originalCallback(node)
|
||||||
@@ -720,6 +741,17 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
|||||||
'node:slot-links:changed': (slotLinksEvent) => {
|
'node:slot-links:changed': (slotLinksEvent) => {
|
||||||
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
|
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
|
||||||
refreshNodeSlots(String(slotLinksEvent.nodeId))
|
refreshNodeSlots(String(slotLinksEvent.nodeId))
|
||||||
|
|
||||||
|
if (slotLinksEvent.connected) {
|
||||||
|
const node = nodeRefs.get(String(slotLinksEvent.nodeId))
|
||||||
|
const slotName = node?.inputs?.[slotLinksEvent.slotIndex]?.name
|
||||||
|
if (node && slotName && app.rootGraph) {
|
||||||
|
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||||
|
if (execId) {
|
||||||
|
useExecutionErrorStore().clearSimpleNodeErrors(execId, slotName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
|||||||
missingModelCandidates.value = null
|
missingModelCandidates.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeMissingModelByWidget(nodeId: string, widgetName: string) {
|
||||||
|
if (!missingModelCandidates.value) return
|
||||||
|
missingModelCandidates.value = missingModelCandidates.value.filter(
|
||||||
|
(m) => !(String(m.nodeId) === nodeId && m.widgetName === widgetName)
|
||||||
|
)
|
||||||
|
if (!missingModelCandidates.value.length)
|
||||||
|
missingModelCandidates.value = null
|
||||||
|
}
|
||||||
|
|
||||||
function hasMissingModelOnNode(nodeLocatorId: string): boolean {
|
function hasMissingModelOnNode(nodeLocatorId: string): boolean {
|
||||||
return missingModelNodeIds.value.has(nodeLocatorId)
|
return missingModelNodeIds.value.has(nodeLocatorId)
|
||||||
}
|
}
|
||||||
@@ -178,6 +187,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
|||||||
|
|
||||||
setMissingModels,
|
setMissingModels,
|
||||||
removeMissingModelByNameOnNodes,
|
removeMissingModelByNameOnNodes,
|
||||||
|
removeMissingModelByWidget,
|
||||||
clearMissingModels,
|
clearMissingModels,
|
||||||
createVerificationAbortController,
|
createVerificationAbortController,
|
||||||
|
|
||||||
|
|||||||
@@ -164,3 +164,249 @@ describe('executionErrorStore — missing node operations', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('executionErrorStore — node error operations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clearSimpleNodeErrors', () => {
|
||||||
|
it('does nothing if lastNodeErrors is null', () => {
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
store.lastNodeErrors = null
|
||||||
|
store.clearSimpleNodeErrors('123', 'widgetName')
|
||||||
|
expect(store.lastNodeErrors).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears entirely if there are only simple errors for the same slot', () => {
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
store.lastNodeErrors = {
|
||||||
|
'123': {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: 'value_bigger_than_max',
|
||||||
|
message: 'Max exceeded',
|
||||||
|
details: '',
|
||||||
|
extra_info: { input_name: 'testSlot' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dependent_outputs: [],
|
||||||
|
class_type: 'TestNode'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.clearSimpleNodeErrors('123', 'testSlot')
|
||||||
|
|
||||||
|
expect(store.lastNodeErrors).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears only the specific slot errors, leaving other errors alone', () => {
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
store.lastNodeErrors = {
|
||||||
|
'123': {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: 'value_bigger_than_max',
|
||||||
|
message: 'Max exceeded',
|
||||||
|
details: '',
|
||||||
|
extra_info: { input_name: 'testSlot' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'required_input_missing',
|
||||||
|
message: 'Missing',
|
||||||
|
details: '',
|
||||||
|
extra_info: { input_name: 'otherSlot' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dependent_outputs: [],
|
||||||
|
class_type: 'TestNode'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.clearSimpleNodeErrors('123', 'testSlot')
|
||||||
|
|
||||||
|
expect(store.lastNodeErrors).not.toBeNull()
|
||||||
|
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||||
|
expect(
|
||||||
|
store.lastNodeErrors?.['123'].errors[0].extra_info?.input_name
|
||||||
|
).toBe('otherSlot')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing if executionId is not found in lastNodeErrors', () => {
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
store.lastNodeErrors = {
|
||||||
|
'123': {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: 'value_bigger_than_max',
|
||||||
|
message: 'Max exceeded',
|
||||||
|
details: '',
|
||||||
|
extra_info: { input_name: 'testSlot' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dependent_outputs: [],
|
||||||
|
class_type: 'TestNode'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.clearSimpleNodeErrors('999', 'testSlot')
|
||||||
|
|
||||||
|
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves complex errors when slot has both simple and complex errors', () => {
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
store.lastNodeErrors = {
|
||||||
|
'123': {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: 'value_bigger_than_max',
|
||||||
|
message: 'Max exceeded',
|
||||||
|
details: '',
|
||||||
|
extra_info: { input_name: 'testSlot' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'exception_type',
|
||||||
|
message: 'Runtime error',
|
||||||
|
details: '',
|
||||||
|
extra_info: { input_name: 'testSlot' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dependent_outputs: [],
|
||||||
|
class_type: 'TestNode'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.clearSimpleNodeErrors('123', 'testSlot')
|
||||||
|
|
||||||
|
// Mixed simple+complex: not all are simple, so none are cleared
|
||||||
|
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears one node while preserving another in multi-node errors', () => {
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
store.lastNodeErrors = {
|
||||||
|
'123': {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: 'value_bigger_than_max',
|
||||||
|
message: 'Max exceeded',
|
||||||
|
details: '',
|
||||||
|
extra_info: { input_name: 'steps' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dependent_outputs: [],
|
||||||
|
class_type: 'KSampler'
|
||||||
|
},
|
||||||
|
'456': {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: 'exception_type',
|
||||||
|
message: 'Runtime failure',
|
||||||
|
details: '',
|
||||||
|
extra_info: { input_name: 'model' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dependent_outputs: [],
|
||||||
|
class_type: 'LoadModel'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.clearSimpleNodeErrors('123', 'steps')
|
||||||
|
|
||||||
|
expect(store.lastNodeErrors?.['123']).toBeUndefined()
|
||||||
|
expect(store.lastNodeErrors?.['456'].errors).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not clear if the error is not simple', () => {
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
store.lastNodeErrors = {
|
||||||
|
'123': {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: 'exception_type',
|
||||||
|
message: 'Failed execution',
|
||||||
|
details: '',
|
||||||
|
extra_info: { input_name: 'testSlot' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dependent_outputs: [],
|
||||||
|
class_type: 'TestNode'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.clearSimpleNodeErrors('123', 'testSlot')
|
||||||
|
|
||||||
|
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clearSimpleWidgetErrorIfValid', () => {
|
||||||
|
it('clears error if value is valid', () => {
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
store.lastNodeErrors = {
|
||||||
|
'123': {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: 'value_bigger_than_max',
|
||||||
|
message: '...',
|
||||||
|
details: '',
|
||||||
|
extra_info: { input_name: 'testWidget' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dependent_outputs: [],
|
||||||
|
class_type: 'TestNode'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.clearSimpleWidgetErrorIfValid('123', 'testWidget', 5, { max: 10 })
|
||||||
|
|
||||||
|
expect(store.lastNodeErrors).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('optimistically clears value_not_in_list error for string combo values', () => {
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
store.lastNodeErrors = {
|
||||||
|
'123': {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: 'value_not_in_list',
|
||||||
|
message: 'Value not in list',
|
||||||
|
details: '',
|
||||||
|
extra_info: { input_name: 'sampler' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dependent_outputs: [],
|
||||||
|
class_type: 'KSampler'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.clearSimpleWidgetErrorIfValid('123', 'sampler', 'euler_a')
|
||||||
|
|
||||||
|
expect(store.lastNodeErrors).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not clear error if value is still out of range', () => {
|
||||||
|
const store = useExecutionErrorStore()
|
||||||
|
store.lastNodeErrors = {
|
||||||
|
'123': {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
type: 'value_bigger_than_max',
|
||||||
|
message: '...',
|
||||||
|
details: '',
|
||||||
|
extra_info: { input_name: 'testWidget' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dependent_outputs: [],
|
||||||
|
class_type: 'TestNode'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.clearSimpleWidgetErrorIfValid('123', 'testWidget', 15, { max: 10 })
|
||||||
|
|
||||||
|
expect(store.lastNodeErrors).not.toBeNull()
|
||||||
|
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ import {
|
|||||||
getExecutionIdByNode,
|
getExecutionIdByNode,
|
||||||
getActiveGraphNodeIds
|
getActiveGraphNodeIds
|
||||||
} from '@/utils/graphTraversalUtil'
|
} from '@/utils/graphTraversalUtil'
|
||||||
|
import {
|
||||||
|
isValueStillOutOfRange,
|
||||||
|
SIMPLE_ERROR_TYPES
|
||||||
|
} from '@/utils/executionErrorUtil'
|
||||||
|
|
||||||
interface MissingNodesError {
|
interface MissingNodesError {
|
||||||
message: string
|
message: string
|
||||||
@@ -112,6 +116,90 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
|||||||
lastPromptError.value = null
|
lastPromptError.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a node's errors if they consist entirely of simple, auto-resolvable
|
||||||
|
* types. When `slotName` is provided, only errors for that slot are checked.
|
||||||
|
*/
|
||||||
|
function clearSimpleNodeErrors(executionId: string, slotName?: string): void {
|
||||||
|
if (!lastNodeErrors.value) return
|
||||||
|
const nodeError = lastNodeErrors.value[executionId]
|
||||||
|
if (!nodeError) return
|
||||||
|
|
||||||
|
const hasTargetName = slotName !== undefined
|
||||||
|
|
||||||
|
const relevantErrors = hasTargetName
|
||||||
|
? nodeError.errors.filter((e) => e.extra_info?.input_name === slotName)
|
||||||
|
: nodeError.errors
|
||||||
|
|
||||||
|
if (
|
||||||
|
relevantErrors.length === 0 ||
|
||||||
|
!relevantErrors.every((e) => SIMPLE_ERROR_TYPES.has(e.type))
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = { ...lastNodeErrors.value }
|
||||||
|
|
||||||
|
if (hasTargetName) {
|
||||||
|
const remainingErrors = nodeError.errors.filter(
|
||||||
|
(e) => e.extra_info?.input_name !== slotName
|
||||||
|
)
|
||||||
|
if (remainingErrors.length === 0) {
|
||||||
|
delete updated[executionId]
|
||||||
|
} else {
|
||||||
|
updated[executionId] = {
|
||||||
|
...nodeError,
|
||||||
|
errors: remainingErrors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete updated[executionId]
|
||||||
|
}
|
||||||
|
|
||||||
|
lastNodeErrors.value = Object.keys(updated).length > 0 ? updated : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to clear an error for a given widget, but avoids clearing it if
|
||||||
|
* the error is a range violation and the new value is still out of bounds.
|
||||||
|
*/
|
||||||
|
function clearSimpleWidgetErrorIfValid(
|
||||||
|
executionId: string,
|
||||||
|
widgetName: string,
|
||||||
|
newValue: unknown,
|
||||||
|
options?: { min?: number; max?: number }
|
||||||
|
): void {
|
||||||
|
if (typeof newValue === 'number' && lastNodeErrors.value) {
|
||||||
|
const nodeErrors = lastNodeErrors.value[executionId]
|
||||||
|
if (nodeErrors) {
|
||||||
|
const errs = nodeErrors.errors.filter(
|
||||||
|
(e) => e.extra_info?.input_name === widgetName
|
||||||
|
)
|
||||||
|
if (isValueStillOutOfRange(newValue, errs, options || {})) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearSimpleNodeErrors(executionId, widgetName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears both validation errors and missing model state for a widget.
|
||||||
|
*/
|
||||||
|
function clearWidgetRelatedErrors(
|
||||||
|
executionId: string,
|
||||||
|
errorInputName: string,
|
||||||
|
widgetName: string,
|
||||||
|
newValue: unknown,
|
||||||
|
options?: { min?: number; max?: number }
|
||||||
|
): void {
|
||||||
|
clearSimpleWidgetErrorIfValid(
|
||||||
|
executionId,
|
||||||
|
errorInputName,
|
||||||
|
newValue,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
missingModelStore.removeMissingModelByWidget(executionId, widgetName)
|
||||||
|
}
|
||||||
|
|
||||||
/** Set missing node types and open the error overlay if the Errors tab is enabled. */
|
/** Set missing node types and open the error overlay if the Errors tab is enabled. */
|
||||||
function surfaceMissingNodes(types: MissingNodeType[]) {
|
function surfaceMissingNodes(types: MissingNodeType[]) {
|
||||||
setMissingNodeTypes(types)
|
setMissingNodeTypes(types)
|
||||||
@@ -401,6 +489,11 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
|||||||
clearAllErrors,
|
clearAllErrors,
|
||||||
clearPromptError,
|
clearPromptError,
|
||||||
|
|
||||||
|
// Clearing (targeted)
|
||||||
|
clearSimpleNodeErrors,
|
||||||
|
clearSimpleWidgetErrorIfValid,
|
||||||
|
clearWidgetRelatedErrors,
|
||||||
|
|
||||||
// Overlay UI
|
// Overlay UI
|
||||||
isErrorOverlayOpen,
|
isErrorOverlayOpen,
|
||||||
showErrorOverlay,
|
showErrorOverlay,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
isCloudValidationError,
|
isCloudValidationError,
|
||||||
tryExtractValidationError,
|
tryExtractValidationError,
|
||||||
classifyCloudValidationError
|
classifyCloudValidationError,
|
||||||
|
isValueStillOutOfRange
|
||||||
} from '@/utils/executionErrorUtil'
|
} from '@/utils/executionErrorUtil'
|
||||||
|
|
||||||
describe('executionErrorUtil', () => {
|
describe('executionErrorUtil', () => {
|
||||||
@@ -187,4 +188,106 @@ describe('executionErrorUtil', () => {
|
|||||||
expect(result?.kind).toBe('promptError')
|
expect(result?.kind).toBe('promptError')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('isValueStillOutOfRange', () => {
|
||||||
|
it('should return false if there are no errors', () => {
|
||||||
|
expect(isValueStillOutOfRange(5, [], {})).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true if value is bigger than max', () => {
|
||||||
|
const errors = [
|
||||||
|
{
|
||||||
|
type: 'value_bigger_than_max',
|
||||||
|
message: 'too big',
|
||||||
|
details: '',
|
||||||
|
extra_info: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
expect(isValueStillOutOfRange(15, errors, { max: 10 })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if value equals max', () => {
|
||||||
|
const errors = [
|
||||||
|
{
|
||||||
|
type: 'value_bigger_than_max',
|
||||||
|
message: 'too big',
|
||||||
|
details: '',
|
||||||
|
extra_info: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
expect(isValueStillOutOfRange(10, errors, { max: 10 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if value is less than max', () => {
|
||||||
|
const errors = [
|
||||||
|
{
|
||||||
|
type: 'value_bigger_than_max',
|
||||||
|
message: 'too big',
|
||||||
|
details: '',
|
||||||
|
extra_info: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
expect(isValueStillOutOfRange(5, errors, { max: 10 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true if value is smaller than min', () => {
|
||||||
|
const errors = [
|
||||||
|
{
|
||||||
|
type: 'value_smaller_than_min',
|
||||||
|
message: 'too small',
|
||||||
|
details: '',
|
||||||
|
extra_info: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
expect(isValueStillOutOfRange(1, errors, { min: 5 })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if value equals min', () => {
|
||||||
|
const errors = [
|
||||||
|
{
|
||||||
|
type: 'value_smaller_than_min',
|
||||||
|
message: 'too small',
|
||||||
|
details: '',
|
||||||
|
extra_info: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
expect(isValueStillOutOfRange(5, errors, { min: 5 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true when max is undefined (conservative)', () => {
|
||||||
|
const errors = [
|
||||||
|
{
|
||||||
|
type: 'value_bigger_than_max',
|
||||||
|
message: 'too big',
|
||||||
|
details: '',
|
||||||
|
extra_info: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
expect(isValueStillOutOfRange(15, errors, {})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true when min is undefined (conservative)', () => {
|
||||||
|
const errors = [
|
||||||
|
{
|
||||||
|
type: 'value_smaller_than_min',
|
||||||
|
message: 'too small',
|
||||||
|
details: '',
|
||||||
|
extra_info: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
expect(isValueStillOutOfRange(0, errors, {})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when errors contain only non-range types', () => {
|
||||||
|
const errors = [
|
||||||
|
{
|
||||||
|
type: 'value_not_in_list',
|
||||||
|
message: 'not in list',
|
||||||
|
details: '',
|
||||||
|
extra_info: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
expect(isValueStillOutOfRange(5, errors, { min: 1, max: 10 })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -90,3 +90,35 @@ export function classifyCloudValidationError(
|
|||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error types that can be resolved automatically when the user changes a
|
||||||
|
* widget value or establishes a connection, without requiring a re-run.
|
||||||
|
*/
|
||||||
|
export const SIMPLE_ERROR_TYPES = new Set([
|
||||||
|
'value_bigger_than_max',
|
||||||
|
'value_smaller_than_min',
|
||||||
|
'value_not_in_list',
|
||||||
|
'required_input_missing'
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if `value` still violates a recorded range constraint.
|
||||||
|
* Pass errors already filtered to the target widget (by `input_name`).
|
||||||
|
* `options` should contain the widget's configured `min` / `max`.
|
||||||
|
*
|
||||||
|
* Returns true (keeps the error) when a bound is unknown (`undefined`).
|
||||||
|
*/
|
||||||
|
export function isValueStillOutOfRange(
|
||||||
|
value: number,
|
||||||
|
errors: NodeError['errors'],
|
||||||
|
options: { min?: number; max?: number }
|
||||||
|
): boolean {
|
||||||
|
const hasMaxError = errors.some((e) => e.type === 'value_bigger_than_max')
|
||||||
|
const hasMinError = errors.some((e) => e.type === 'value_smaller_than_min')
|
||||||
|
|
||||||
|
return (
|
||||||
|
(hasMaxError && (options.max === undefined || value > options.max)) ||
|
||||||
|
(hasMinError && (options.min === undefined || value < options.min))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user