Refine validation error catalog messages

This commit is contained in:
jaeone94
2026-05-24 23:08:56 +09:00
parent b9f2020a6c
commit 3bc0b154b2
5 changed files with 492 additions and 38 deletions

View File

@@ -109,7 +109,9 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
expect(screen.getAllByText('Prompt has no outputs').length).toBeGreaterThan(
0
)
expect(
screen.getByText(
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'

View File

@@ -29,17 +29,47 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
vi.mock('@/i18n', () => ({
te: vi.fn(() => false),
st: vi.fn((_key: string, fallback: string) => fallback),
t: vi.fn((key: string, params?: { count?: number }) => {
if (key === 'errorOverlay.missingModels') {
const count = params?.count ?? 0
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
}
return key
})
}))
vi.mock('@/i18n', () => {
const messages: Record<string, string> = {
'errorCatalog.validationErrors.required_input_missing.title':
'Missing connection',
'errorCatalog.validationErrors.required_input_missing.message':
'Required input slots have no connection feeding them.',
'errorCatalog.validationErrors.required_input_missing.details':
'{nodeName} is missing a required input: {inputName}',
'errorCatalog.validationErrors.required_input_missing.itemLabel':
'{nodeName} - {inputName}',
'errorCatalog.validationErrors.required_input_missing.toastTitle':
'Required input missing',
'errorCatalog.validationErrors.required_input_missing.toastMessage':
'{nodeName} is missing a required input: {inputName}',
'errorCatalog.promptErrors.prompt_no_outputs.title':
'Prompt has no outputs',
'errorCatalog.promptErrors.prompt_no_outputs.desc':
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
}
const interpolate = (
message: string,
params?: Record<string, string | number>
) =>
message.replace(/\{(\w+)\}/g, (match, paramName) =>
params?.[paramName] === undefined ? match : String(params[paramName])
)
return {
te: vi.fn((key: string) => key in messages),
st: vi.fn((key: string, fallback: string) => messages[key] ?? fallback),
t: vi.fn((key: string, params?: Record<string, string | number>) => {
if (key === 'errorOverlay.missingModels') {
const count = Number(params?.count ?? 0)
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
}
return interpolate(messages[key] ?? key, params)
})
}
})
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: () => ({
@@ -412,10 +442,14 @@ describe('useErrorGroups', () => {
)
expect(execGroups.length).toBeGreaterThan(0)
if (execGroups[0].type !== 'execution') return
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBe('KSampler')
expect(execGroups[0].cards[0].errors[0].toastTitle).toBe(
'KSampler failed'
)
expect(execGroups[0].cards[0].errors[0]).toMatchObject({
message: 'RuntimeError: CUDA out of memory',
details: 'line 1\nline 2',
isRuntimeError: true,
exceptionType: 'RuntimeError'
})
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBeUndefined()
expect(execGroups[0].cards[0].errors[0].toastTitle).toBeUndefined()
})
it('includes prompt error when present', async () => {
@@ -428,7 +462,8 @@ describe('useErrorGroups', () => {
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution' && g.displayTitle === 'No outputs'
(g) =>
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
)
expect(promptGroup).toBeDefined()
})

View File

@@ -3671,7 +3671,7 @@
},
"bad_linked_input": {
"title": "Invalid connection",
"message": "A linked input connection is malformed.",
"message": "A node connection could not be read correctly.",
"details": "{nodeName} has an invalid connection for {inputName}.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Invalid connection",
@@ -3681,46 +3681,57 @@
"title": "Invalid connection",
"message": "Connected nodes are using incompatible input and output types.",
"details": "{nodeName} has an incompatible connection for {inputName}.",
"detailsWithTypes": "{nodeName}'s {inputName} input expects {expectedType}, but the connected output is {receivedType}.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Invalid connection",
"toastMessage": "{nodeName} has an incompatible connection for {inputName}."
"toastMessage": "{nodeName} has an incompatible connection for {inputName}.",
"toastMessageWithTypes": "{nodeName}'s {inputName} input expects {expectedType}, but the connected output is {receivedType}."
},
"invalid_input_type": {
"title": "Invalid input",
"message": "An input value has the wrong type.",
"details": "{nodeName} couldn't convert {inputName} to the expected type.",
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} couldn't be converted to {expectedType}.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Invalid input",
"toastMessage": "{nodeName} couldn't convert {inputName} to the expected type."
"toastMessage": "{nodeName} couldn't convert {inputName} to the expected type.",
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} couldn't be converted to {expectedType}."
},
"value_smaller_than_min": {
"title": "Input out of range",
"message": "Some input values are outside the allowed range.",
"details": "{nodeName} has a value below the minimum for {inputName}.",
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is below the minimum {minValue}.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Input out of range",
"toastMessage": "{nodeName} has a value below the minimum for {inputName}."
"toastMessage": "{nodeName} has a value below the minimum for {inputName}.",
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is below the minimum {minValue}."
},
"value_bigger_than_max": {
"title": "Input out of range",
"message": "Some input values are outside the allowed range.",
"details": "{nodeName} has a value above the maximum for {inputName}.",
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is above the maximum {maxValue}.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Input out of range",
"toastMessage": "{nodeName} has a value above the maximum for {inputName}."
"toastMessage": "{nodeName} has a value above the maximum for {inputName}.",
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is above the maximum {maxValue}."
},
"value_not_in_list": {
"title": "Invalid input",
"message": "Some input values are not available for this node.",
"details": "{nodeName} has an unsupported value for {inputName}.",
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is not available.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Invalid input",
"toastMessage": "{nodeName} has an unsupported value for {inputName}."
"toastMessage": "{nodeName} has an unsupported value for {inputName}.",
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is not available."
},
"custom_validation_failed": {
"title": "Invalid input",
"message": "A node rejected one or more input values.",
"details": "{nodeName} rejected the value for {inputName}.",
"detailsWithRawDetails": "{nodeName} failed custom validation: {rawDetails}",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Invalid input",
"toastMessage": "{nodeName} rejected the value for {inputName}."
@@ -3729,22 +3740,27 @@
"title": "Validation failed",
"message": "The workflow couldn't validate a connected node.",
"details": "{nodeName} couldn't validate {inputName}.",
"detailsWithRawDetails": "{nodeName} couldn't validate {inputName}: {rawDetails}",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Validation failed",
"toastMessage": "{nodeName} couldn't validate {inputName}."
"toastMessage": "{nodeName} couldn't validate {inputName}.",
"toastMessageWithRawDetails": "{nodeName} couldn't validate {inputName}: {rawDetails}"
},
"exception_during_validation": {
"title": "Validation failed",
"message": "The node could not be validated.",
"details": "{nodeName} could not be validated.",
"message": "The workflow could not be validated because a node validation check failed unexpectedly.",
"details": "{nodeName} failed during validation.",
"detailsWithRawDetails": "{nodeName} failed during validation: {rawDetails}",
"itemLabel": "{nodeName}",
"toastTitle": "Validation failed",
"toastMessage": "{nodeName} could not be validated."
"toastMessage": "{nodeName} failed during validation.",
"toastMessageWithRawDetails": "{nodeName} failed during validation: {rawDetails}"
},
"dependency_cycle": {
"title": "Invalid workflow",
"message": "The workflow has a circular node connection.",
"details": "{nodeName} is part of a circular connection.",
"detailsWithRawDetails": "{nodeName} is part of a circular connection: {rawDetails}",
"itemLabel": "{nodeName}",
"toastTitle": "Invalid workflow",
"toastMessage": "{nodeName} is part of a circular connection."

View File

@@ -11,17 +11,22 @@ import { i18n } from '@/i18n'
function nodeValidationError(
type: string,
inputName?: string,
details = inputName ?? ''
details = inputName ?? '',
extraInfo: Record<string, unknown> = {}
): NodeValidationError {
const extra_info =
inputName || Object.keys(extraInfo).length > 0
? {
...(inputName ? { input_name: inputName } : {}),
...extraInfo
}
: undefined
return {
type,
message: 'Validation failed',
details,
extra_info: inputName
? {
input_name: inputName
}
: undefined
extra_info
}
}
@@ -175,6 +180,160 @@ describe('errorMessageResolver', () => {
).toEqual(expected)
})
it('includes received values in validation range and option details', () => {
expect(
resolveRunErrorMessage({
kind: 'node_validation',
error: nodeValidationError(
'return_type_mismatch',
'images',
'images, received_type(LATENT) mismatch input_type(IMAGE)',
{
input_config: ['IMAGE', {}],
received_type: 'LATENT'
}
),
nodeDisplayName: 'Preview Image'
})
).toMatchObject({
displayDetails:
"Preview Image's images input expects IMAGE, but the connected output is LATENT.",
toastMessage:
"Preview Image's images input expects IMAGE, but the connected output is LATENT."
})
expect(
resolveRunErrorMessage({
kind: 'node_validation',
error: nodeValidationError(
'invalid_input_type',
'steps',
"steps, abc, invalid literal for int() with base 10: 'abc'",
{
input_config: ['INT', {}],
received_value: 'abc'
}
),
nodeDisplayName: 'KSampler'
})
).toMatchObject({
displayDetails:
"The value abc for KSampler's steps couldn't be converted to INT.",
toastMessage:
"The value abc for KSampler's steps couldn't be converted to INT."
})
expect(
resolveRunErrorMessage({
kind: 'node_validation',
error: nodeValidationError('value_smaller_than_min', 'steps', 'steps', {
input_config: ['INT', { min: 1 }],
received_value: 0
}),
nodeDisplayName: 'KSampler'
})
).toMatchObject({
displayDetails:
"The value 0 for KSampler's steps is below the minimum 1.",
toastMessage: "The value 0 for KSampler's steps is below the minimum 1."
})
expect(
resolveRunErrorMessage({
kind: 'node_validation',
error: nodeValidationError('value_bigger_than_max', 'cfg', 'cfg', {
input_config: ['FLOAT', { max: 30 }],
received_value: 40
}),
nodeDisplayName: 'KSampler'
})
).toMatchObject({
displayDetails:
"The value 40 for KSampler's cfg is above the maximum 30.",
toastMessage: "The value 40 for KSampler's cfg is above the maximum 30."
})
expect(
resolveRunErrorMessage({
kind: 'node_validation',
error: nodeValidationError(
'value_not_in_list',
'scheduler',
'scheduler',
{
received_value: 'not-a-scheduler'
}
),
nodeDisplayName: 'KSampler'
})
).toMatchObject({
displayDetails:
"The value not-a-scheduler for KSampler's scheduler is not available.",
toastMessage:
"The value not-a-scheduler for KSampler's scheduler is not available."
})
})
it('includes raw details when validation itself fails unexpectedly', () => {
expect(
resolveRunErrorMessage({
kind: 'node_validation',
error: nodeValidationError(
'exception_during_inner_validation',
'images',
'list index out of range'
),
nodeDisplayName: 'Image Scale'
})
).toMatchObject({
displayTitle: 'Validation failed',
displayMessage: "The workflow couldn't validate a connected node.",
displayDetails:
"Image Scale couldn't validate images: list index out of range",
displayItemLabel: 'Image Scale - images',
toastTitle: 'Validation failed',
toastMessage:
"Image Scale couldn't validate images: list index out of range"
})
expect(
resolveRunErrorMessage({
kind: 'node_validation',
error: nodeValidationError(
'exception_during_validation',
undefined,
'tuple index out of range'
),
nodeDisplayName: 'Preview Image'
})
).toMatchObject({
displayTitle: 'Validation failed',
displayMessage:
'The workflow could not be validated because a node validation check failed unexpectedly.',
displayDetails:
'Preview Image failed during validation: tuple index out of range',
displayItemLabel: 'Preview Image',
toastTitle: 'Validation failed',
toastMessage:
'Preview Image failed during validation: tuple index out of range'
})
expect(
resolveRunErrorMessage({
kind: 'node_validation',
error: nodeValidationError(
'exception_during_validation',
undefined,
''
),
nodeDisplayName: 'Preview Image'
})
).toMatchObject({
displayDetails: 'Preview Image failed during validation.',
toastMessage: 'Preview Image failed during validation.'
})
})
it('resolves custom validation image failures as image-not-loaded copy', () => {
expect(
resolveRunErrorMessage({
@@ -197,6 +356,51 @@ describe('errorMessageResolver', () => {
})
})
it('includes raw details for generic custom validation failures', () => {
expect(
resolveRunErrorMessage({
kind: 'node_validation',
error: nodeValidationError(
'custom_validation_failed',
'setting',
'setting - Unsupported lab value: bad-value'
),
nodeDisplayName: 'Custom Validation Error'
})
).toMatchObject({
catalogId: 'custom_validation_failed',
displayTitle: 'Invalid input',
displayMessage: 'A node rejected one or more input values.',
displayDetails:
'Custom Validation Error failed custom validation: setting - Unsupported lab value: bad-value',
displayItemLabel: 'Custom Validation Error - setting',
toastTitle: 'Invalid input',
toastMessage: 'Custom Validation Error rejected the value for setting.'
})
})
it('includes raw cycle paths for dependency cycle details', () => {
expect(
resolveRunErrorMessage({
kind: 'node_validation',
error: nodeValidationError(
'dependency_cycle',
undefined,
'7 (ImageScale) -> 7 (ImageScale)'
),
nodeDisplayName: 'Image Scale'
})
).toMatchObject({
displayTitle: 'Invalid workflow',
displayMessage: 'The workflow has a circular node connection.',
displayDetails:
'Image Scale is part of a circular connection: 7 (ImageScale) to 7 (ImageScale)',
displayItemLabel: 'Image Scale',
toastTitle: 'Invalid workflow',
toastMessage: 'Image Scale is part of a circular connection.'
})
})
it('resolves known prompt errors with run error rules', () => {
expect(
resolveRunErrorMessage({

View File

@@ -30,10 +30,12 @@ interface ErrorResolveContext {
nodeDisplayName?: string
}
type CatalogParams = Record<string, string | number>
function translateCatalogMessage(
key: string,
fallback: string,
params?: Record<string, string | number>
params?: CatalogParams
): string {
if (te(key)) return params ? t(key, params) : t(key)
if (!params) return fallback
@@ -46,7 +48,7 @@ function translateCatalogMessage(
function translateOptionalCatalogMessage(
key: string,
fallback?: string,
params?: Record<string, string | number>
params?: CatalogParams
): string | undefined {
if (te(key)) return params ? t(key, params) : t(key)
return fallback?.trim() ? fallback : undefined
@@ -91,6 +93,190 @@ function nodeInputItemLabel(nodeName: string, inputName: string): string {
return `${nodeName} - ${inputName}`
}
function formatRawDetailsForCatalog(details: string): string {
return details.replace(/\s*->\s*/g, ' to ')
}
function formatCatalogValue(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
function getInputConfigValue(
error: NodeValidationError,
key: 'min' | 'max'
): string | undefined {
const inputConfig = error.extra_info?.input_config
if (!Array.isArray(inputConfig)) return undefined
const config = inputConfig[1]
if (!config || typeof config !== 'object') return undefined
return formatCatalogValue((config as Record<string, unknown>)[key])
}
function getInputConfigType(error: NodeValidationError): string | undefined {
const inputConfig = error.extra_info?.input_config
if (!Array.isArray(inputConfig)) return undefined
return formatCatalogValue(inputConfig[0])
}
function getValidationParams(
error: NodeValidationError,
nodeName: string,
inputName: string
): CatalogParams {
const params: CatalogParams = { nodeName, inputName }
const receivedValue = formatCatalogValue(error.extra_info?.received_value)
const receivedType = formatCatalogValue(error.extra_info?.received_type)
const expectedType = getInputConfigType(error)
const minValue = getInputConfigValue(error, 'min')
const maxValue = getInputConfigValue(error, 'max')
if (receivedValue !== undefined) params.receivedValue = receivedValue
if (receivedType !== undefined) params.receivedType = receivedType
if (expectedType !== undefined) params.expectedType = expectedType
if (minValue !== undefined) params.minValue = minValue
if (maxValue !== undefined) params.maxValue = maxValue
return params
}
function hasParams(params: CatalogParams, keys: string[]): boolean {
return keys.every((key) => params[key] !== undefined)
}
function getValueSpecificCopyKeys(
errorType: string,
params: CatalogParams
): {
detailsKey: string
toastMessageKey: string
} {
switch (errorType) {
case 'return_type_mismatch':
if (hasParams(params, ['expectedType', 'receivedType'])) {
return {
detailsKey: 'detailsWithTypes',
toastMessageKey: 'toastMessageWithTypes'
}
}
break
case 'invalid_input_type':
if (hasParams(params, ['receivedValue', 'expectedType'])) {
return {
detailsKey: 'detailsWithValue',
toastMessageKey: 'toastMessageWithValue'
}
}
break
case 'value_smaller_than_min':
if (hasParams(params, ['receivedValue', 'minValue'])) {
return {
detailsKey: 'detailsWithValue',
toastMessageKey: 'toastMessageWithValue'
}
}
break
case 'value_bigger_than_max':
if (hasParams(params, ['receivedValue', 'maxValue'])) {
return {
detailsKey: 'detailsWithValue',
toastMessageKey: 'toastMessageWithValue'
}
}
break
case 'value_not_in_list':
if (hasParams(params, ['receivedValue'])) {
return {
detailsKey: 'detailsWithValue',
toastMessageKey: 'toastMessageWithValue'
}
}
break
}
return {
detailsKey: 'details',
toastMessageKey: 'toastMessage'
}
}
function getRawDetailsCopyKeys(error: NodeValidationError): {
detailsKey: string
toastMessageKey: string
} {
return error.details.trim()
? {
detailsKey: 'detailsWithRawDetails',
toastMessageKey: 'toastMessageWithRawDetails'
}
: {
detailsKey: 'details',
toastMessageKey: 'toastMessage'
}
}
function getExceptionDuringValidationCopyKeys(error: NodeValidationError): {
detailsKey: string
toastMessageKey: string
} {
return getRawDetailsCopyKeys(error)
}
function getRawDetailsOnlyCopyKeys(error: NodeValidationError): {
detailsKey: string
toastMessageKey: string
} {
if (!error.details.trim()) {
return {
detailsKey: 'details',
toastMessageKey: 'toastMessage'
}
}
return {
detailsKey: 'detailsWithRawDetails',
toastMessageKey: 'toastMessage'
}
}
function getValidationCopyKeys(
error: NodeValidationError,
params: CatalogParams
): {
detailsKey: string
toastMessageKey: string
} {
if (error.type === 'exception_during_validation') {
return getExceptionDuringValidationCopyKeys(error)
}
if (error.type === 'exception_during_inner_validation') {
return getRawDetailsCopyKeys(error)
}
if (error.type === 'custom_validation_failed') {
return getRawDetailsOnlyCopyKeys(error)
}
if (error.type === 'dependency_cycle') {
return getRawDetailsOnlyCopyKeys(error)
}
return getValueSpecificCopyKeys(error.type, params)
}
const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = {
[REQUIRED_INPUT_MISSING_TYPE]: {
catalogId: REQUIRED_INPUT_MISSING_CATALOG_ID,
@@ -162,13 +348,24 @@ function resolveValidationCatalogCopy(
): ResolvedErrorMessage {
const nodeName = normalizeNodeName(context.nodeDisplayName)
const inputName = getInputName(error)
const params = { nodeName, inputName }
const rawDetails = formatRawDetailsForCatalog(error.details.trim())
const params = {
...getValidationParams(error, nodeName, inputName),
rawDetails
}
const keyPrefix = `errorCatalog.validationErrors.${rule.key}`
const titleFallback = error.type || error.message
const itemLabelFallback =
rule.itemLabel === 'node'
? nodeName
: nodeInputItemLabel(nodeName, inputName)
const copyKeys =
rule.key === 'image_not_loaded'
? {
detailsKey: 'details',
toastMessageKey: 'toastMessage'
}
: getValidationCopyKeys(error, params)
return {
catalogId: rule.catalogId,
@@ -183,7 +380,7 @@ function resolveValidationCatalogCopy(
params
),
displayDetails: translateOptionalCatalogMessage(
`${keyPrefix}.details`,
`${keyPrefix}.${copyKeys.detailsKey}`,
error.details,
params
),
@@ -198,7 +395,7 @@ function resolveValidationCatalogCopy(
params
),
toastMessage: translateCatalogMessage(
`${keyPrefix}.toastMessage`,
`${keyPrefix}.${copyKeys.toastMessageKey}`,
error.message,
params
)