mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-25 07:05:26 +00:00
## Summary Automatically clears transient validation errors (`value_bigger_than_max`, `value_smaller_than_min`, `value_not_in_list`, `required_input_missing`) when the user modifies a widget value or connects an input slot, so resolved errors don't linger in the error panel. Also clears missing model state when the user changes a combo widget value. ## Changes - **`useNodeErrorAutoResolve` composable**: watches widget changes and slot connections, clears matching errors via `executionErrorStore` - **`executionErrorStore`**: adds `clearSimpleNodeErrors` and `clearSimpleWidgetErrorIfValid` with granular per-slot error removal - **`executionErrorUtil`**: adds `isValueStillOutOfRange` to prevent premature clearing when a new value still violates the constraint - **`graphTraversalUtil`**: adds `getExecutionIdFromNodeData` for subgraph-aware execution ID resolution - **`GraphCanvas.vue`**: fixes subgraph error key lookup by using `getExecutionIdByNode` instead of raw `node.id` - **`NodeWidgets.vue`**: wires up the new composable to the widget layer - **`missingModelStore`**: adds `removeMissingModelByWidget` to clear missing model state on widget value change - **`useGraphNodeManager`**: registers composable per node - **Tests**: 126 new unit tests covering error clearing, range validation, and graph traversal edge cases ## Screenshots https://github.com/user-attachments/assets/515ea811-ff84-482a-a866-a17e5c779c39 https://github.com/user-attachments/assets/a2b30f02-4929-4537-952c-a0febe20f02e ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9464-feat-auto-resolve-simple-validation-errors-on-widget-change-and-slot-connection-31b6d73d3650816b8afdc34f4b40295a) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
343 lines
10 KiB
TypeScript
343 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
isCloudValidationError,
|
|
tryExtractValidationError,
|
|
classifyCloudValidationError,
|
|
isValueStillOutOfRange
|
|
} from '@/utils/executionErrorUtil'
|
|
|
|
describe('executionErrorUtil', () => {
|
|
describe('isCloudValidationError', () => {
|
|
it('should return true when object has error field', () => {
|
|
expect(isCloudValidationError({ error: 'some error' })).toBe(true)
|
|
})
|
|
|
|
it('should return true when object has node_errors field', () => {
|
|
expect(isCloudValidationError({ node_errors: {} })).toBe(true)
|
|
})
|
|
|
|
it('should return true when object has both fields', () => {
|
|
expect(isCloudValidationError({ error: 'err', node_errors: {} })).toBe(
|
|
true
|
|
)
|
|
})
|
|
|
|
it('should return false for null', () => {
|
|
expect(isCloudValidationError(null)).toBe(false)
|
|
})
|
|
|
|
it('should return false for non-object', () => {
|
|
expect(isCloudValidationError('string')).toBe(false)
|
|
})
|
|
|
|
it('should return false for object without error or node_errors', () => {
|
|
expect(isCloudValidationError({ foo: 'bar' })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('tryExtractValidationError', () => {
|
|
it('should extract JSON from a message with embedded validation error', () => {
|
|
const embedded = JSON.stringify({
|
|
error: {
|
|
type: 'prompt_no_outputs',
|
|
message: 'No outputs',
|
|
details: ''
|
|
},
|
|
node_errors: {}
|
|
})
|
|
const message = `Failed to send prompt request: status 400: ${embedded}`
|
|
|
|
const result = tryExtractValidationError(message)
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result?.error).toEqual({
|
|
type: 'prompt_no_outputs',
|
|
message: 'No outputs',
|
|
details: ''
|
|
})
|
|
})
|
|
|
|
it('should return null when message has no JSON', () => {
|
|
expect(tryExtractValidationError('plain error message')).toBeNull()
|
|
})
|
|
|
|
it('should return null when JSON is not a validation error shape', () => {
|
|
const message = 'error: {"foo": "bar"}'
|
|
expect(tryExtractValidationError(message)).toBeNull()
|
|
})
|
|
|
|
it('should return null when JSON is malformed', () => {
|
|
const message = 'error: {invalid json'
|
|
expect(tryExtractValidationError(message)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('classifyCloudValidationError', () => {
|
|
it('should classify node errors when node_errors is present', () => {
|
|
const nodeErrors = {
|
|
'11:1': {
|
|
errors: [
|
|
{
|
|
type: 'required_input_missing',
|
|
message: 'Required input is missing',
|
|
details: 'clip',
|
|
extra_info: { input_name: 'clip' }
|
|
}
|
|
],
|
|
dependent_outputs: ['9'],
|
|
class_type: 'CLIPTextEncode'
|
|
}
|
|
}
|
|
const embedded = JSON.stringify({
|
|
error: {
|
|
type: 'prompt_outputs_failed_validation',
|
|
message: 'Prompt outputs failed validation',
|
|
details: ''
|
|
},
|
|
node_errors: nodeErrors
|
|
})
|
|
const message = `Failed to send prompt request: status 400: ${embedded}`
|
|
|
|
const result = classifyCloudValidationError(message)
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result?.kind).toBe('nodeErrors')
|
|
if (result?.kind === 'nodeErrors') {
|
|
expect(result.nodeErrors['11:1'].class_type).toBe('CLIPTextEncode')
|
|
}
|
|
})
|
|
|
|
it('should classify prompt error when error is an object and no node_errors', () => {
|
|
const embedded = JSON.stringify({
|
|
error: {
|
|
type: 'prompt_no_outputs',
|
|
message: 'Prompt has no outputs',
|
|
details: ''
|
|
}
|
|
})
|
|
const message = `Failed: ${embedded}`
|
|
|
|
const result = classifyCloudValidationError(message)
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result?.kind).toBe('promptError')
|
|
if (result?.kind === 'promptError') {
|
|
expect(result.promptError.type).toBe('prompt_no_outputs')
|
|
expect(result.promptError.message).toBe('Prompt has no outputs')
|
|
}
|
|
})
|
|
|
|
it('should classify prompt error when error is a string', () => {
|
|
const embedded = JSON.stringify({ error: 'Something went wrong' })
|
|
const message = `Failed: ${embedded}`
|
|
|
|
const result = classifyCloudValidationError(message)
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result?.kind).toBe('promptError')
|
|
if (result?.kind === 'promptError') {
|
|
expect(result.promptError.type).toBe('error')
|
|
expect(result.promptError.message).toBe('Something went wrong')
|
|
}
|
|
})
|
|
|
|
it('should return null when message has no embedded JSON', () => {
|
|
expect(classifyCloudValidationError('plain error')).toBeNull()
|
|
})
|
|
|
|
it('should return null when embedded JSON has no error or node_errors', () => {
|
|
const message = 'error: {"foo": "bar"}'
|
|
expect(classifyCloudValidationError(message)).toBeNull()
|
|
})
|
|
|
|
it('should return null when error field is neither object nor string', () => {
|
|
const embedded = JSON.stringify({ error: 123 })
|
|
const message = `Failed: ${embedded}`
|
|
|
|
expect(classifyCloudValidationError(message)).toBeNull()
|
|
})
|
|
|
|
it('should prefer node_errors over error when both present', () => {
|
|
const embedded = JSON.stringify({
|
|
error: { type: 'validation', message: 'fail', details: '' },
|
|
node_errors: {
|
|
'5': {
|
|
errors: [{ type: 'err', message: 'bad', details: '' }],
|
|
dependent_outputs: [],
|
|
class_type: 'KSampler'
|
|
}
|
|
}
|
|
})
|
|
const message = `Failed: ${embedded}`
|
|
|
|
const result = classifyCloudValidationError(message)
|
|
|
|
expect(result?.kind).toBe('nodeErrors')
|
|
})
|
|
|
|
it('should treat empty node_errors as prompt error', () => {
|
|
const embedded = JSON.stringify({
|
|
error: { type: 'no_prompt', message: 'No prompt', details: '' },
|
|
node_errors: {}
|
|
})
|
|
const message = `Failed: ${embedded}`
|
|
|
|
const result = classifyCloudValidationError(message)
|
|
|
|
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 is equal to max but error was value_bigger_than_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 is equal to min but error was value_smaller_than_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 false if value is greater than min', () => {
|
|
const errors = [
|
|
{
|
|
type: 'value_smaller_than_min',
|
|
message: 'too small',
|
|
details: '',
|
|
extra_info: {}
|
|
}
|
|
]
|
|
expect(isValueStillOutOfRange(10, errors, { min: 5 })).toBe(false)
|
|
})
|
|
|
|
it('should return true if both max and min errors exist and value is still out of range', () => {
|
|
const errors = [
|
|
{
|
|
type: 'value_bigger_than_max',
|
|
message: 'too big',
|
|
details: '',
|
|
extra_info: {}
|
|
},
|
|
{
|
|
type: 'value_smaller_than_min',
|
|
message: 'too small',
|
|
details: '',
|
|
extra_info: {}
|
|
}
|
|
]
|
|
// Value above max — still out of range for the max error
|
|
expect(isValueStillOutOfRange(15, errors, { min: 1, max: 10 })).toBe(true)
|
|
})
|
|
|
|
it('should return false if both max and min errors exist but value is in range', () => {
|
|
const errors = [
|
|
{
|
|
type: 'value_bigger_than_max',
|
|
message: 'too big',
|
|
details: '',
|
|
extra_info: {}
|
|
},
|
|
{
|
|
type: 'value_smaller_than_min',
|
|
message: 'too small',
|
|
details: '',
|
|
extra_info: {}
|
|
}
|
|
]
|
|
expect(isValueStillOutOfRange(5, errors, { min: 1, max: 10 })).toBe(false)
|
|
})
|
|
|
|
it('should return true if max is undefined but error was value_bigger_than_max (conservative)', () => {
|
|
const errors = [
|
|
{
|
|
type: 'value_bigger_than_max',
|
|
message: 'too big',
|
|
details: '',
|
|
extra_info: {}
|
|
}
|
|
]
|
|
expect(isValueStillOutOfRange(15, errors, {})).toBe(true)
|
|
})
|
|
|
|
it('should return true if min is undefined but error was value_smaller_than_min (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)
|
|
})
|
|
})
|
|
})
|