diff --git a/src/components/rightSidePanel/errors/TabErrors.test.ts b/src/components/rightSidePanel/errors/TabErrors.test.ts index adb3c448e3..1c7e89951b 100644 --- a/src/components/rightSidePanel/errors/TabErrors.test.ts +++ b/src/components/rightSidePanel/errors/TabErrors.test.ts @@ -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.' diff --git a/src/components/rightSidePanel/errors/useErrorGroups.test.ts b/src/components/rightSidePanel/errors/useErrorGroups.test.ts index 7d106f0d8f..4925c89c08 100644 --- a/src/components/rightSidePanel/errors/useErrorGroups.test.ts +++ b/src/components/rightSidePanel/errors/useErrorGroups.test.ts @@ -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 = { + '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 + ) => + 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) => { + 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() }) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 846902d0fa..32665ebc86 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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." diff --git a/src/platform/errorCatalog/errorMessageResolver.test.ts b/src/platform/errorCatalog/errorMessageResolver.test.ts index df1fed73e6..282d536c54 100644 --- a/src/platform/errorCatalog/errorMessageResolver.test.ts +++ b/src/platform/errorCatalog/errorMessageResolver.test.ts @@ -11,17 +11,22 @@ import { i18n } from '@/i18n' function nodeValidationError( type: string, inputName?: string, - details = inputName ?? '' + details = inputName ?? '', + extraInfo: Record = {} ): 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({ diff --git a/src/platform/errorCatalog/errorMessageResolver.ts b/src/platform/errorCatalog/errorMessageResolver.ts index ee2bea78ab..eef1463a49 100644 --- a/src/platform/errorCatalog/errorMessageResolver.ts +++ b/src/platform/errorCatalog/errorMessageResolver.ts @@ -30,10 +30,12 @@ interface ErrorResolveContext { nodeDisplayName?: string } +type CatalogParams = Record + function translateCatalogMessage( key: string, fallback: string, - params?: Record + 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 + 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)[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 = { [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 )