diff --git a/apps/desktop-ui/src/components/maintenance/TaskListPanel.vue b/apps/desktop-ui/src/components/maintenance/TaskListPanel.vue index 5021aa0a15a..f667cacb118 100644 --- a/apps/desktop-ui/src/components/maintenance/TaskListPanel.vue +++ b/apps/desktop-ui/src/components/maintenance/TaskListPanel.vue @@ -76,7 +76,7 @@ const executeTask = async (task: MaintenanceTask) => { message = t('maintenance.error.taskFailed') } catch (error) { - message = (error as Error)?.message + message = error instanceof Error ? error.message : undefined } toast.add({ diff --git a/apps/desktop-ui/src/stores/maintenanceTaskStore.ts b/apps/desktop-ui/src/stores/maintenanceTaskStore.ts index a371327ed55..235d370ccf8 100644 --- a/apps/desktop-ui/src/stores/maintenanceTaskStore.ts +++ b/apps/desktop-ui/src/stores/maintenanceTaskStore.ts @@ -66,7 +66,7 @@ class MaintenanceTaskRunner { this.error = undefined return true } catch (error) { - this.error = (error as Error)?.message + this.error = error instanceof Error ? error.message : String(error) throw error } finally { this.executing = false diff --git a/eslint.config.ts b/eslint.config.ts index 108a68cff55..e2b06e3d0aa 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -230,6 +230,31 @@ export default defineConfig([ ] } }, + { + name: 'comfy/no-unsafe-error-assertion', + files: [ + 'src/**/*.ts', + 'src/**/*.tsx', + 'src/**/*.vue', + 'apps/*/src/**/*.ts', + 'apps/*/src/**/*.tsx', + 'apps/*/src/**/*.vue' + ], + ignores: ['**/*.test.ts', '**/*.spec.ts'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + // Bans `value as Error` and `value as Error & { ... }`. + // Use `error instanceof Error` narrowing or `toError()` from + // @/utils/errorUtil instead — see issue #11429. + selector: "TSAsExpression TSTypeReference[typeName.name='Error']", + message: + 'Do not use `as Error` assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.' + } + ] + } + }, { files: ['**/*.spec.ts'], ignores: ['browser_tests/tests/**/*.spec.ts', 'apps/*/e2e/**/*.spec.ts'], diff --git a/src/extensions/core/load3d/LoaderManager.ts b/src/extensions/core/load3d/LoaderManager.ts index 4879e9df313..fce9699d1ed 100644 --- a/src/extensions/core/load3d/LoaderManager.ts +++ b/src/extensions/core/load3d/LoaderManager.ts @@ -23,8 +23,15 @@ import type { */ function isNotFoundError(error: unknown): boolean { if (!(error instanceof Error)) return false - const withResponse = error as Error & { response?: { status?: number } } - if (withResponse.response?.status === 404) return true + if ( + 'response' in error && + typeof error.response === 'object' && + error.response !== null && + 'status' in error.response && + error.response.status === 404 + ) { + return true + } return /\b404\b/.test(error.message) } diff --git a/src/platform/cloud/onboarding/auth.ts b/src/platform/cloud/onboarding/auth.ts index e5d148303f7..30a1d7878e5 100644 --- a/src/platform/cloud/onboarding/auth.ts +++ b/src/platform/cloud/onboarding/auth.ts @@ -2,6 +2,7 @@ import * as Sentry from '@sentry/vue' import { isEmpty } from 'es-toolkit/compat' import { api } from '@/scripts/api' +import { toError } from '@/utils/errorUtil' interface UserCloudStatus { status: 'active' @@ -80,7 +81,7 @@ export async function getUserCloudStatus(): Promise { } catch (error) { // Only capture network errors (not HTTP errors we already captured) if (!isHttpError(error, 'Failed to get user:')) { - captureApiError(error as Error, '/user', 'network_error') + captureApiError(toError(error), '/user', 'network_error') } throw error } @@ -176,7 +177,7 @@ export async function submitSurvey( // Only capture network errors (not HTTP errors we already captured) if (!isHttpError(error, 'Failed to submit survey:')) { captureApiError( - error as Error, + toError(error), '/settings', 'network_error', undefined, diff --git a/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder.ts b/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder.ts index ca0cc3d8518..2393ccdd483 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder.ts @@ -2,6 +2,7 @@ import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recor import { onUnmounted, ref } from 'vue' import { useAudioService } from '@/services/audioService' +import { toError } from '@/utils/errorUtil' interface AudioRecorderOptions { onRecordingComplete?: (audioBlob: Blob) => Promise @@ -62,7 +63,7 @@ export function useAudioRecorder(options: AudioRecorderOptions = {}) { isRecording.value = true } catch (err) { if (options.onError) { - options.onError(err as Error) + options.onError(toError(err)) } throw err } diff --git a/src/services/gateway/registrySearchGateway.ts b/src/services/gateway/registrySearchGateway.ts index 5714d661e7c..92a05a6c4aa 100644 --- a/src/services/gateway/registrySearchGateway.ts +++ b/src/services/gateway/registrySearchGateway.ts @@ -6,6 +6,7 @@ import type { NodePackSearchProvider, SearchPacksResult } from '@/types/searchServiceTypes' +import { toError } from '@/utils/errorUtil' type RegistryNodePack = components['schemas']['Node'] @@ -152,7 +153,7 @@ export const useRegistrySearchGateway = (): NodePackSearchProvider => { recordSuccess(providerState) return result } catch (error) { - lastError = error as Error + lastError = toError(error) const providerState = providers[activeProviderIndex] recordFailure(providerState, lastError) console.warn( diff --git a/src/utils/errorUtil.test.ts b/src/utils/errorUtil.test.ts new file mode 100644 index 00000000000..053181ff417 --- /dev/null +++ b/src/utils/errorUtil.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' + +import { getErrorMessage, toError } from './errorUtil' + +describe('toError', () => { + it('returns the same Error instance when given an Error', () => { + const err = new Error('boom') + expect(toError(err)).toBe(err) + }) + + it('preserves Error subclasses', () => { + class CustomError extends Error {} + const err = new CustomError('subclass') + expect(toError(err)).toBe(err) + expect(toError(err)).toBeInstanceOf(CustomError) + }) + + it('wraps a string as an Error message', () => { + const result = toError('plain string') + expect(result).toBeInstanceOf(Error) + expect(result.message).toBe('plain string') + }) + + it('wraps a number by stringifying it', () => { + const result = toError(42) + expect(result).toBeInstanceOf(Error) + expect(result.message).toBe('42') + }) + + it('wraps an object via JSON.stringify', () => { + const result = toError({ code: 'EBOOM', detail: 'nope' }) + expect(result).toBeInstanceOf(Error) + expect(result.message).toBe('{"code":"EBOOM","detail":"nope"}') + }) + + it('falls back to String() when JSON.stringify throws (circular)', () => { + const obj: Record = {} + obj.self = obj + const result = toError(obj) + expect(result).toBeInstanceOf(Error) + expect(result.message).toBe('[object Object]') + }) + + it('handles null and undefined', () => { + expect(toError(null).message).toBe('null') + expect(toError(undefined).message).toBe('undefined') + }) +}) + +describe('getErrorMessage', () => { + it('returns the message of an Error', () => { + expect(getErrorMessage(new Error('boom'))).toBe('boom') + }) + + it('returns the value when given a string', () => { + expect(getErrorMessage('text')).toBe('text') + }) + + it('returns the message field of a plain object', () => { + expect(getErrorMessage({ message: 'from object' })).toBe('from object') + }) + + it('returns undefined for objects without a string message', () => { + expect(getErrorMessage({ code: 1 })).toBeUndefined() + expect(getErrorMessage({ message: 42 })).toBeUndefined() + }) + + it('returns undefined for null, undefined, numbers, booleans', () => { + expect(getErrorMessage(null)).toBeUndefined() + expect(getErrorMessage(undefined)).toBeUndefined() + expect(getErrorMessage(42)).toBeUndefined() + expect(getErrorMessage(true)).toBeUndefined() + }) +}) diff --git a/src/utils/errorUtil.ts b/src/utils/errorUtil.ts new file mode 100644 index 00000000000..ee8c95450e4 --- /dev/null +++ b/src/utils/errorUtil.ts @@ -0,0 +1,37 @@ +/** + * Narrow an unknown caught value to an Error. + * + * Replaces unsafe `value as Error` assertions. When `value` is not already + * an Error instance, wraps it in a new Error whose message is the stringified + * input so downstream consumers (loggers, Sentry, toasts) always receive a + * usable Error object instead of `undefined.message`. + */ +export function toError(value: unknown): Error { + if (value instanceof Error) return value + if (typeof value === 'string') return new Error(value) + if (value === undefined) return new Error('undefined') + try { + const serialised = JSON.stringify(value) + return new Error(serialised ?? String(value)) + } catch { + return new Error(String(value)) + } +} + +/** + * Extract a message from an unknown caught value without asserting its type. + * Returns `undefined` when the value carries no usable message. + */ +export function getErrorMessage(value: unknown): string | undefined { + if (value instanceof Error) return value.message + if (typeof value === 'string') return value + if ( + typeof value === 'object' && + value !== null && + 'message' in value && + typeof (value as { message: unknown }).message === 'string' + ) { + return (value as { message: string }).message + } + return undefined +}