Compare commits

..

2 Commits

Author SHA1 Message Date
GitHub Action
16233b2621 [automated] Apply ESLint and Oxfmt fixes 2026-03-12 17:18:30 +00:00
CodeRabbit Fixer
470726d122 fix: test: add missing unit tests for executionErrorStore and useNodeErrorAutoResolve (followup from #9464) (#9797)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:15:06 +01:00
18 changed files with 673 additions and 30 deletions

View File

@@ -11,6 +11,8 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { usePromotionStore } from '@/stores/promotionStore'
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()
})
})

View File

@@ -36,6 +36,8 @@ import type {
import type { TitleMode } 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 { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
export interface WidgetSlotMetadata {
index: number
@@ -560,6 +562,25 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
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
if (originalCallback) {
void originalCallback(node)
@@ -720,6 +741,17 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
'node:slot-links:changed': (slotLinksEvent) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
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)
}
}
}
}
}
}

View File

@@ -59,7 +59,10 @@ function mkFileUrl(props: { ref: ImageRef; preview?: boolean }): string {
}
const pathPlusQueryParams = api.apiURL(
'/view?' + params.toString() + app.getPreviewFormatParam()
'/view?' +
params.toString() +
app.getPreviewFormatParam() +
app.getRandParam()
)
const imageElement = new Image()
imageElement.crossOrigin = 'anonymous'

View File

@@ -17,7 +17,7 @@ type MockTask = {
executionEndTimestamp?: number
previewOutput?: {
isImage: boolean
url: string
urlWithTimestamp: string
}
}
@@ -94,7 +94,7 @@ describe(useQueueNotificationBanners, () => {
if (previewUrl) {
task.previewOutput = {
isImage,
url: previewUrl
urlWithTimestamp: previewUrl
}
}

View File

@@ -231,7 +231,7 @@ export const useQueueNotificationBanners = () => {
completedCount++
const preview = task.previewOutput
if (preview?.isImage) {
imagePreviews.push(preview.url)
imagePreviews.push(preview.urlWithTimestamp)
}
} else if (state === 'failed') {
failedCount++

View File

@@ -1,6 +1,7 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
type ImageCompareOutput = NodeOutputWith<{
@@ -23,10 +24,11 @@ useExtensionService().registerExtension({
onExecuted?.call(this, output)
const { a_images: aImages, b_images: bImages } = output
const rand = app.getRandParam()
const toUrl = (record: Record<string, string>) => {
const params = new URLSearchParams(record)
return api.apiURL(`/view?${params}`)
return api.apiURL(`/view?${params}${rand}`)
}
const beforeImages =

View File

@@ -2,6 +2,7 @@ import type Load3d from '@/extensions/core/load3d/Load3d'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
class Load3dUtils {
static async generateThumbnailIfNeeded(
@@ -132,7 +133,8 @@ class Load3dUtils {
const params = [
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder
'subfolder=' + subfolder,
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`

View File

@@ -116,6 +116,15 @@ export const useMissingModelStore = defineStore('missingModel', () => {
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 {
return missingModelNodeIds.value.has(nodeLocatorId)
}
@@ -178,6 +187,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
setMissingModels,
removeMissingModelByNameOnNodes,
removeMissingModelByWidget,
clearMissingModels,
createVerificationAbortController,

View File

@@ -1,5 +1,5 @@
<template>
<WidgetLayoutField v-slot="{ borderStyle }" :widget :no-border="!hasLabels">
<WidgetLayoutField :widget>
<!-- Use ToggleGroup when explicit labels are provided -->
<ToggleGroup
v-if="hasLabels"
@@ -25,13 +25,7 @@
<!-- Use ToggleSwitch for implicit boolean states -->
<div
v-else
:class="
cn(
'-m-1 flex w-fit items-center gap-2 rounded-full p-1',
hideLayoutField || 'ml-auto',
borderStyle
)
"
:class="cn('flex w-fit items-center gap-2', hideLayoutField || 'ml-auto')"
>
<ToggleSwitch
v-model="modelValue"

View File

@@ -1,26 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useHideLayoutField } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
const { widget, rootClass } = defineProps<{
const { rootClass } = defineProps<{
widget: Pick<
SimplifiedWidget<string | number | undefined>,
'name' | 'label' | 'borderStyle'
>
rootClass?: string
noBorder?: boolean
}>()
const hideLayoutField = useHideLayoutField()
const borderStyle = computed(() =>
cn(
'focus-within:ring focus-within:ring-component-node-widget-background-highlighted',
widget.borderStyle
)
)
</script>
<template>
@@ -42,15 +33,15 @@ const borderStyle = computed(() =>
<div
:class="
cn(
'min-w-0 cursor-default rounded-lg transition-all',
!noBorder && borderStyle
'min-w-0 cursor-default rounded-lg transition-all has-focus-visible:ring has-focus-visible:ring-component-node-widget-background-highlighted',
widget.borderStyle
)
"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<slot :border-style />
<slot />
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import type { ResultItemType } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
/**
* Format time in MM:SS format
@@ -19,7 +20,8 @@ export function getResourceURL(
const params = [
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder
'subfolder=' + subfolder,
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`

View File

@@ -382,6 +382,11 @@ export class ComfyApp {
else return ''
}
getRandParam() {
if (isCloud) return ''
return '&rand=' + Math.random()
}
static onClipspaceEditorSave() {
if (ComfyApp.clipspace_return_node) {
ComfyApp.pasteFromClipspace(ComfyApp.clipspace_return_node)

View File

@@ -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)
})
})
})

View File

@@ -29,6 +29,10 @@ import {
getExecutionIdByNode,
getActiveGraphNodeIds
} from '@/utils/graphTraversalUtil'
import {
isValueStillOutOfRange,
SIMPLE_ERROR_TYPES
} from '@/utils/executionErrorUtil'
interface MissingNodesError {
message: string
@@ -112,6 +116,90 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
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. */
function surfaceMissingNodes(types: MissingNodeType[]) {
setMissingNodeTypes(types)
@@ -401,6 +489,11 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
clearAllErrors,
clearPromptError,
// Clearing (targeted)
clearSimpleNodeErrors,
clearSimpleWidgetErrorIfValid,
clearWidgetRelatedErrors,
// Overlay UI
isErrorOverlayOpen,
showErrorOverlay,

View File

@@ -117,11 +117,12 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
const outputs = getNodeOutputs(node)
if (!outputs?.images?.length) return
const rand = app.getRandParam()
const previewParam = getPreviewParam(node, outputs)
return outputs.images.map((image) => {
const params = new URLSearchParams(image)
return api.apiURL(`/view?${params}${previewParam}`)
return api.apiURL(`/view?${params}${previewParam}${rand}`)
})
}

View File

@@ -104,6 +104,10 @@ export class ResultItemImpl {
return api.apiURL('/view?' + params)
}
get urlWithTimestamp(): string {
return `${this.url}&t=${+new Date()}`
}
get isVhsFormat(): boolean {
return !!this.format && !!this.frame_rate
}

View File

@@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest'
import {
isCloudValidationError,
tryExtractValidationError,
classifyCloudValidationError
classifyCloudValidationError,
isValueStillOutOfRange
} from '@/utils/executionErrorUtil'
describe('executionErrorUtil', () => {
@@ -187,4 +188,106 @@ describe('executionErrorUtil', () => {
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)
})
})
})

View File

@@ -90,3 +90,35 @@ export function classifyCloudValidationError(
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))
)
}