+
+
{
})
const chevronClass = computed(() =>
- cn('mr-2 size-4 transition-transform duration-200 flex-shrink-0', {
- 'rotate-180': props.isOpen
- })
+ cn(
+ 'mr-2 size-4 transition-transform duration-200 flex-shrink-0 text-button-icon',
+ {
+ 'rotate-180': props.isOpen
+ }
+ )
)
const theButtonStyle = computed(() =>
- cn('bg-transparent border-0 outline-none text-zinc-400', {
+ cn('bg-transparent border-0 outline-none text-text-secondary', {
'hover:bg-node-component-widget-input-surface/30 cursor-pointer':
!props.disabled,
- 'cursor-not-allowed': props.disabled
+ 'cursor-not-allowed': props.disabled,
+ 'text-text-primary': selectedItems.value.length > 0
})
)
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useAudioRecordWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useAudioRecordWidget.ts
deleted file mode 100644
index efa11a43b..000000000
--- a/src/renderer/extensions/vueNodes/widgets/composables/useAudioRecordWidget.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
-import type { IAudioRecordWidget } from '@/lib/litegraph/src/types/widgets'
-import type {
- AudioRecordInputSpec,
- InputSpec as InputSpecV2
-} from '@/schemas/nodeDef/nodeDefSchemaV2'
-import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
-
-export const useAudioRecordWidget = (): ComfyWidgetConstructorV2 => {
- return (node: LGraphNode, inputSpec: InputSpecV2): IAudioRecordWidget => {
- const {
- name,
- default: defaultValue = '',
- options = {}
- } = inputSpec as AudioRecordInputSpec
-
- const widget = node.addWidget('audiorecord', name, defaultValue, () => {}, {
- serialize: true,
- ...options
- }) as IAudioRecordWidget
-
- return widget
- }
-}
diff --git a/src/renderer/extensions/vueNodes/widgets/utils/audioUtils.ts b/src/renderer/extensions/vueNodes/widgets/utils/audioUtils.ts
index 35e0c4482..12e7fdf4f 100644
--- a/src/renderer/extensions/vueNodes/widgets/utils/audioUtils.ts
+++ b/src/renderer/extensions/vueNodes/widgets/utils/audioUtils.ts
@@ -1,5 +1,6 @@
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
+import { app } from '@/scripts/app'
/**
* Format time in MM:SS format
@@ -23,10 +24,6 @@ export function getAudioUrlFromPath(
return api.apiURL(getResourceURL(subfolder, filename, type))
}
-function getRandParam() {
- return '&rand=' + Math.random()
-}
-
export function getResourceURL(
subfolder: string,
filename: string,
@@ -36,7 +33,7 @@ export function getResourceURL(
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder,
- getRandParam().substring(1)
+ app.getRandParam().substring(1)
].join('&')
return `/view?${params}`
diff --git a/src/schemas/nodeDef/nodeDefSchemaV2.ts b/src/schemas/nodeDef/nodeDefSchemaV2.ts
index f77c59a80..09983d115 100644
--- a/src/schemas/nodeDef/nodeDefSchemaV2.ts
+++ b/src/schemas/nodeDef/nodeDefSchemaV2.ts
@@ -152,13 +152,6 @@ const zTextareaInputSpec = zBaseInputOptions.extend({
.optional()
})
-const zAudioRecordInputSpec = zBaseInputOptions.extend({
- type: z.literal('AUDIORECORD'),
- name: z.string(),
- isOptional: z.boolean().optional(),
- options: z.record(z.unknown()).optional()
-})
-
const zCustomInputSpec = zBaseInputOptions.extend({
type: z.string(),
name: z.string(),
@@ -174,7 +167,6 @@ const zInputSpec = z.union([
zColorInputSpec,
zFileUploadInputSpec,
zImageInputSpec,
- zAudioRecordInputSpec,
zImageCompareInputSpec,
zMarkdownInputSpec,
zTreeSelectInputSpec,
@@ -230,7 +222,6 @@ export type GalleriaInputSpec = z.infer
export type SelectButtonInputSpec = z.infer
export type TextareaInputSpec = z.infer
export type CustomInputSpec = z.infer
-export type AudioRecordInputSpec = z.infer
export type InputSpec = z.infer
export type OutputSpec = z.infer
diff --git a/src/scripts/app.ts b/src/scripts/app.ts
index 043c76862..9da116612 100644
--- a/src/scripts/app.ts
+++ b/src/scripts/app.ts
@@ -16,6 +16,7 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
+import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
@@ -336,6 +337,7 @@ export class ComfyApp {
}
getRandParam() {
+ if (isCloud) return ''
return '&rand=' + Math.random()
}
diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts
index 97fd3f829..dd75081af 100644
--- a/src/scripts/widgets.ts
+++ b/src/scripts/widgets.ts
@@ -6,7 +6,6 @@ import type {
IStringWidget
} from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
-import { useAudioRecordWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useAudioRecordWidget'
import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
@@ -305,6 +304,5 @@ export const ComfyWidgets: Record = {
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
SELECTBUTTON: transformWidgetConstructorV2ToV1(useSelectButtonWidget()),
- TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
- AUDIO_RECORD: transformWidgetConstructorV2ToV1(useAudioRecordWidget())
+ TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget())
}
diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts
index b731e978b..6c84d7db2 100644
--- a/src/services/litegraphService.ts
+++ b/src/services/litegraphService.ts
@@ -8,7 +8,6 @@ import { addWidgetPromotionOptions } from '@/core/graph/subgraph/proxyWidgetUtil
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
import { st, t } from '@/i18n'
import {
- LGraphBadge,
LGraphCanvas,
LGraphEventMode,
LGraphNode,
@@ -135,19 +134,6 @@ export const useLitegraphService = () => {
this.#setInitialSize()
this.serialize_widgets = true
void extensionService.invokeExtensionsAsync('nodeCreated', this)
- this.badges.push(
- new LGraphBadge({
- text: '',
- iconOptions: {
- unicode: '\ue96e',
- fontFamily: 'PrimeIcons',
- color: '#ffffff',
- fontSize: 12
- },
- fgColor: '#ffffff',
- bgColor: '#3b82f6'
- })
- )
}
/**
@@ -845,7 +831,7 @@ export const useLitegraphService = () => {
)
}
if (this.graph && !this.graph.isRootGraph) {
- const [x, y] = canvas.canvas_mouse
+ const [x, y] = canvas.graph_mouse
const overWidget = this.getWidgetOnPos(x, y, true)
if (overWidget) {
addWidgetPromotionOptions(options, overWidget, this)
diff --git a/src/types/treeExplorerTypes.ts b/src/types/treeExplorerTypes.ts
index 7cc639be9..316a8fb17 100644
--- a/src/types/treeExplorerTypes.ts
+++ b/src/types/treeExplorerTypes.ts
@@ -54,7 +54,7 @@ export interface TreeExplorerNode extends TreeNode {
event: MouseEvent
) => void | Promise
/** Function to handle errors */
- handleError?: (this: TreeExplorerNode, error: Error) => void
+ handleError?: (this: TreeExplorerNode, error: unknown) => void
/** Extra context menu items */
contextMenuItems?:
| MenuItem[]
diff --git a/src/utils/hostWhitelist.ts b/src/utils/hostWhitelist.ts
index 694626ca7..8f9470c50 100644
--- a/src/utils/hostWhitelist.ts
+++ b/src/utils/hostWhitelist.ts
@@ -84,8 +84,8 @@ function isIPv6Loopback(h: string): boolean {
// Count explicit zero groups on each side of '::' to ensure at least one group is compressed.
// (leftCount + rightCount) must be ≤ 6 so that the total expanded groups = 8.
- const leftCount = m[1] ? m[1].match(/0{1,4}:/gi)?.length ?? 0 : 0
- const rightCount = m[2] ? m[2].match(/0{1,4}:/gi)?.length ?? 0 : 0
+ const leftCount = m[1] ? (m[1].match(/0{1,4}:/gi)?.length ?? 0) : 0
+ const rightCount = m[2] ? (m[2].match(/0{1,4}:/gi)?.length ?? 0) : 0
// Require that at least one group was actually compressed: i.e., leftCount + rightCount ≤ 6.
return leftCount + rightCount <= 6
diff --git a/src/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.vue b/src/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.vue
index 3ea7b15e3..643427d79 100644
--- a/src/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.vue
+++ b/src/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.vue
@@ -236,7 +236,7 @@ const handleSubmit = async () => {
// Convert 'latest' to actual version number for installation
const actualVersion =
selectedVersion.value === 'latest'
- ? nodePack.latest_version?.version ?? 'latest'
+ ? (nodePack.latest_version?.version ?? 'latest')
: selectedVersion.value
await managerStore.installPack.call({
diff --git a/src/workbench/extensions/manager/components/manager/button/PackInstallButton.vue b/src/workbench/extensions/manager/components/manager/button/PackInstallButton.vue
index c7def90b9..226ae4f20 100644
--- a/src/workbench/extensions/manager/components/manager/button/PackInstallButton.vue
+++ b/src/workbench/extensions/manager/components/manager/button/PackInstallButton.vue
@@ -74,8 +74,8 @@ const createPayload = (installItem: NodePack) => {
const isUnclaimedPack = installItem.publisher?.name === 'Unclaimed'
const versionToInstall = isUnclaimedPack
? ('nightly' as ManagerComponents['schemas']['SelectedVersion'])
- : installItem.latest_version?.version ??
- ('latest' as ManagerComponents['schemas']['SelectedVersion'])
+ : (installItem.latest_version?.version ??
+ ('latest' as ManagerComponents['schemas']['SelectedVersion']))
return {
id: installItem.id,
@@ -140,7 +140,7 @@ const performInstallation = async (packs: NodePack[]) => {
const computedLabel = computed(() =>
isInstalling.value
? t('g.installing')
- : label ??
- (nodePacks.length > 1 ? t('manager.installSelected') : t('g.install'))
+ : (label ??
+ (nodePacks.length > 1 ? t('manager.installSelected') : t('g.install')))
)
diff --git a/tests-ui/tests/composables/useErrorHandling.test.ts b/tests-ui/tests/composables/useErrorHandling.test.ts
new file mode 100644
index 000000000..99cf3da11
--- /dev/null
+++ b/tests-ui/tests/composables/useErrorHandling.test.ts
@@ -0,0 +1,353 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
+import { useErrorHandling } from '@/composables/useErrorHandling'
+
+describe('useErrorHandling', () => {
+ let errorHandler: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ setActivePinia(createPinia())
+ errorHandler = useErrorHandling()
+ })
+
+ describe('wrapWithErrorHandlingAsync', () => {
+ it('should execute action successfully', async () => {
+ const action = vi.fn(async () => 'success')
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(action)
+
+ const result = await wrapped()
+
+ expect(result).toBe('success')
+ expect(action).toHaveBeenCalledOnce()
+ })
+
+ it('should call error handler when action throws', async () => {
+ const testError = new Error('test error')
+ const action = vi.fn(async () => {
+ throw testError
+ })
+ const customErrorHandler = vi.fn()
+
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(
+ action,
+ customErrorHandler
+ )
+
+ await wrapped()
+
+ expect(customErrorHandler).toHaveBeenCalledWith(testError)
+ })
+
+ it('should call finally handler after success', async () => {
+ const action = vi.fn(async () => 'success')
+ const finallyHandler = vi.fn()
+
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(
+ action,
+ undefined,
+ finallyHandler
+ )
+
+ await wrapped()
+
+ expect(finallyHandler).toHaveBeenCalledOnce()
+ })
+
+ it('should call finally handler after error', async () => {
+ const action = vi.fn(async () => {
+ throw new Error('test error')
+ })
+ const finallyHandler = vi.fn()
+
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(
+ action,
+ vi.fn(),
+ finallyHandler
+ )
+
+ await wrapped()
+
+ expect(finallyHandler).toHaveBeenCalledOnce()
+ })
+
+ describe('error recovery', () => {
+ it('should not use recovery strategy when no error occurs', async () => {
+ const action = vi.fn(async () => 'success')
+ const recoveryStrategy: ErrorRecoveryStrategy = {
+ shouldHandle: vi.fn(() => true),
+ recover: vi.fn()
+ }
+
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(
+ action,
+ undefined,
+ undefined,
+ [recoveryStrategy]
+ )
+
+ await wrapped()
+
+ expect(recoveryStrategy.shouldHandle).not.toHaveBeenCalled()
+ expect(recoveryStrategy.recover).not.toHaveBeenCalled()
+ })
+
+ it('should use recovery strategy when it matches error', async () => {
+ const testError = new Error('test error')
+ const action = vi.fn(async () => {
+ throw testError
+ })
+ const recoveryStrategy: ErrorRecoveryStrategy = {
+ shouldHandle: vi.fn((error) => error === testError),
+ recover: vi.fn(async () => {
+ // Recovery succeeds, does nothing
+ })
+ }
+
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(
+ action,
+ vi.fn(),
+ undefined,
+ [recoveryStrategy]
+ )
+
+ await wrapped()
+
+ expect(recoveryStrategy.shouldHandle).toHaveBeenCalledWith(testError)
+ expect(recoveryStrategy.recover).toHaveBeenCalled()
+ })
+
+ it('should pass action and args to recovery strategy', async () => {
+ const testError = new Error('test error')
+ const action = vi.fn(async (_arg1: string, _arg2: number) => {
+ throw testError
+ })
+ const recoveryStrategy: ErrorRecoveryStrategy<[string, number], void> =
+ {
+ shouldHandle: vi.fn(() => true),
+ recover: vi.fn()
+ }
+
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(
+ action,
+ vi.fn(),
+ undefined,
+ [recoveryStrategy]
+ )
+
+ await wrapped('test', 123)
+
+ expect(recoveryStrategy.recover).toHaveBeenCalledWith(
+ testError,
+ action,
+ ['test', 123]
+ )
+ })
+
+ it('should retry operation when recovery succeeds', async () => {
+ let attemptCount = 0
+ const action = vi.fn(async (value: string) => {
+ attemptCount++
+ if (attemptCount === 1) {
+ throw new Error('first attempt failed')
+ }
+ return `success: ${value}`
+ })
+
+ const recoveryStrategy: ErrorRecoveryStrategy<[string], string> = {
+ shouldHandle: vi.fn(() => true),
+ recover: vi.fn(async (_error, retry, args) => {
+ await retry(...args)
+ })
+ }
+
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(
+ action,
+ vi.fn(),
+ undefined,
+ [recoveryStrategy]
+ )
+
+ await wrapped('test-value')
+
+ expect(action).toHaveBeenCalledTimes(2)
+ expect(recoveryStrategy.recover).toHaveBeenCalledOnce()
+ })
+
+ it('should not call error handler when recovery succeeds', async () => {
+ const action = vi.fn(async () => {
+ throw new Error('test error')
+ })
+ const customErrorHandler = vi.fn()
+ const recoveryStrategy: ErrorRecoveryStrategy = {
+ shouldHandle: vi.fn(() => true),
+ recover: vi.fn(async () => {
+ // Recovery succeeds
+ })
+ }
+
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(
+ action,
+ customErrorHandler,
+ undefined,
+ [recoveryStrategy]
+ )
+
+ await wrapped()
+
+ expect(customErrorHandler).not.toHaveBeenCalled()
+ })
+
+ it('should call error handler when recovery fails', async () => {
+ const originalError = new Error('original error')
+ const recoveryError = new Error('recovery error')
+ const action = vi.fn(async () => {
+ throw originalError
+ })
+ const customErrorHandler = vi.fn()
+ const recoveryStrategy: ErrorRecoveryStrategy = {
+ shouldHandle: vi.fn(() => true),
+ recover: vi.fn(async () => {
+ throw recoveryError
+ })
+ }
+
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(
+ action,
+ customErrorHandler,
+ undefined,
+ [recoveryStrategy]
+ )
+
+ await wrapped()
+
+ expect(customErrorHandler).toHaveBeenCalledWith(originalError)
+ })
+
+ it('should try multiple recovery strategies in order', async () => {
+ const testError = new Error('test error')
+ const action = vi.fn(async () => {
+ throw testError
+ })
+
+ const strategy1: ErrorRecoveryStrategy = {
+ shouldHandle: vi.fn(() => false),
+ recover: vi.fn()
+ }
+
+ const strategy2: ErrorRecoveryStrategy = {
+ shouldHandle: vi.fn(() => true),
+ recover: vi.fn(async () => {
+ // Recovery succeeds
+ })
+ }
+
+ const strategy3: ErrorRecoveryStrategy = {
+ shouldHandle: vi.fn(() => true),
+ recover: vi.fn()
+ }
+
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(
+ action,
+ vi.fn(),
+ undefined,
+ [strategy1, strategy2, strategy3]
+ )
+
+ await wrapped()
+
+ expect(strategy1.shouldHandle).toHaveBeenCalledWith(testError)
+ expect(strategy1.recover).not.toHaveBeenCalled()
+
+ expect(strategy2.shouldHandle).toHaveBeenCalledWith(testError)
+ expect(strategy2.recover).toHaveBeenCalled()
+
+ // Strategy 3 should not be checked because strategy 2 handled it
+ expect(strategy3.shouldHandle).not.toHaveBeenCalled()
+ expect(strategy3.recover).not.toHaveBeenCalled()
+ })
+
+ it('should fall back to error handler when no strategy matches', async () => {
+ const testError = new Error('test error')
+ const action = vi.fn(async () => {
+ throw testError
+ })
+ const customErrorHandler = vi.fn()
+
+ const strategy: ErrorRecoveryStrategy = {
+ shouldHandle: vi.fn(() => false),
+ recover: vi.fn()
+ }
+
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(
+ action,
+ customErrorHandler,
+ undefined,
+ [strategy]
+ )
+
+ await wrapped()
+
+ expect(strategy.shouldHandle).toHaveBeenCalledWith(testError)
+ expect(strategy.recover).not.toHaveBeenCalled()
+ expect(customErrorHandler).toHaveBeenCalledWith(testError)
+ })
+
+ it('should work with synchronous actions', async () => {
+ const testError = new Error('test error')
+ const action = vi.fn(() => {
+ throw testError
+ })
+ const recoveryStrategy: ErrorRecoveryStrategy = {
+ shouldHandle: vi.fn(() => true),
+ recover: vi.fn(async () => {
+ // Recovery succeeds
+ })
+ }
+
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(
+ action,
+ vi.fn(),
+ undefined,
+ [recoveryStrategy]
+ )
+
+ await wrapped()
+
+ expect(recoveryStrategy.recover).toHaveBeenCalled()
+ })
+ })
+
+ describe('backward compatibility', () => {
+ it('should work without recovery strategies parameter', async () => {
+ const action = vi.fn(async () => 'success')
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(action)
+
+ const result = await wrapped()
+
+ expect(result).toBe('success')
+ })
+
+ it('should work with empty recovery strategies array', async () => {
+ const testError = new Error('test error')
+ const action = vi.fn(async () => {
+ throw testError
+ })
+ const customErrorHandler = vi.fn()
+
+ const wrapped = errorHandler.wrapWithErrorHandlingAsync(
+ action,
+ customErrorHandler,
+ undefined,
+ []
+ )
+
+ await wrapped()
+
+ expect(customErrorHandler).toHaveBeenCalledWith(testError)
+ })
+ })
+ })
+})
diff --git a/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.test.ts
index 5b9d658df..61e5577d8 100644
--- a/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.test.ts
+++ b/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.test.ts
@@ -113,7 +113,12 @@ describe('ImagePreview', () => {
// Action buttons should now be visible
expect(wrapper.find('.actions').exists()).toBe(true)
- expect(wrapper.findAll('.action-btn')).toHaveLength(2) // download, remove (no mask for multiple images)
+ // For multiple images: download and remove buttons (no mask button)
+ expect(wrapper.find('[aria-label="Download image"]').exists()).toBe(true)
+ expect(wrapper.find('[aria-label="Remove image"]').exists()).toBe(true)
+ expect(wrapper.find('[aria-label="Edit or mask image"]').exists()).toBe(
+ false
+ )
})
it('hides action buttons when not hovering', async () => {
@@ -203,8 +208,9 @@ describe('ImagePreview', () => {
await navigationDots[1].trigger('click')
await nextTick()
- // After clicking, component shows loading state (Skeleton)
+ // After clicking, component shows loading state (Skeleton), not img
expect(wrapper.find('skeleton-stub').exists()).toBe(true)
+ expect(wrapper.find('img').exists()).toBe(false)
// Simulate image load event to clear loading state
const component = wrapper.vm as any