fix: merge main into webcam-capture branch

This commit is contained in:
Johnpaul
2026-01-26 20:20:03 +01:00
1503 changed files with 151707 additions and 38328 deletions

View File

@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const mockUpdateModelsForNodeType = vi.fn()
const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
getAssets: () => [],
isModelLoading: () => false,
getError: () => undefined,
hasAssetKey: () => false,
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getCategoryForNodeType: mockGetCategoryForNodeType
})
}))
describe('useAssetWidgetData (desktop/isCloud=false)', () => {
it('returns empty/default values without calling stores', () => {
const nodeType = ref('CheckpointLoaderSimple')
const { category, assets, dropdownItems, isLoading, error } =
useAssetWidgetData(nodeType)
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
expect(dropdownItems.value).toEqual([])
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
expect(mockGetCategoryForNodeType).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,254 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
const mockAssetsByKey = new Map<string, AssetItem[]>()
const mockLoadingByKey = new Map<string, boolean>()
const mockErrorByKey = new Map<string, Error | undefined>()
const mockInitializedKeys = new Set<string>()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
getAssets: (key: string) => mockAssetsByKey.get(key) ?? [],
isModelLoading: (key: string) => mockLoadingByKey.get(key) ?? false,
getError: (key: string) => mockErrorByKey.get(key),
hasAssetKey: (key: string) => mockInitializedKeys.has(key),
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getCategoryForNodeType: mockGetCategoryForNodeType
})
}))
describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAssetsByKey.clear()
mockLoadingByKey.clear()
mockErrorByKey.clear()
mockInitializedKeys.clear()
mockGetCategoryForNodeType.mockReturnValue(undefined)
mockUpdateModelsForNodeType.mockImplementation(
async (): Promise<AssetItem[]> => {
return []
}
)
})
const createMockAsset = (
id: string,
name: string,
filename: string,
previewUrl?: string
): AssetItem => ({
id,
name,
size: 1024,
tags: ['models', 'checkpoints'],
created_at: '2025-01-01T00:00:00Z',
preview_url: previewUrl,
user_metadata: {
filename
}
})
it('fetches assets and transforms to dropdown items', async () => {
const mockAssets: AssetItem[] = [
createMockAsset(
'asset-1',
'Beautiful Model',
'models/beautiful_model.safetensors',
'/api/preview/asset-1'
),
createMockAsset('asset-2', 'Model B', 'model_b.safetensors', '/preview/2')
]
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
}
)
const nodeType = ref('CheckpointLoaderSimple')
const { category, assets, dropdownItems, isLoading } =
useAssetWidgetData(nodeType)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(category.value).toBe('checkpoints')
expect(assets.value).toEqual(mockAssets)
expect(dropdownItems.value).toHaveLength(2)
const item = dropdownItems.value[0]
expect(item.id).toBe('asset-1')
expect(item.name).toBe('models/beautiful_model.safetensors')
expect(item.label).toBe('Beautiful Model')
expect(item.mediaSrc).toBe('/api/preview/asset-1')
})
it('handles API errors gracefully', async () => {
const mockError = new Error('Network error')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockInitializedKeys.add(_nodeType)
mockErrorByKey.set(_nodeType, mockError)
mockAssetsByKey.set(_nodeType, [])
mockLoadingByKey.set(_nodeType, false)
return []
}
)
const nodeType = ref('CheckpointLoaderSimple')
const { assets, error, isLoading } = useAssetWidgetData(nodeType)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(error.value).toBe(mockError)
expect(assets.value).toEqual([])
})
it('returns empty for unknown node type', async () => {
mockGetCategoryForNodeType.mockReturnValue(undefined)
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, [])
mockLoadingByKey.set(_nodeType, false)
return []
}
)
const nodeType = ref('UnknownNodeType')
const { category, assets } = useAssetWidgetData(nodeType)
await nextTick()
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
})
describe('MaybeRefOrGetter parameter support', () => {
it('accepts plain string value', async () => {
const mockAssets: AssetItem[] = [
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
]
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
}
)
const { category, assets, isLoading } = useAssetWidgetData(
'CheckpointLoaderSimple'
)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(category.value).toBe('checkpoints')
expect(assets.value).toEqual(mockAssets)
})
it('accepts getter function', async () => {
const mockAssets: AssetItem[] = [
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
]
mockGetCategoryForNodeType.mockReturnValue('loras')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
}
)
const nodeType = ref('LoraLoader')
const { category, assets, isLoading } = useAssetWidgetData(
() => nodeType.value
)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith('LoraLoader')
expect(category.value).toBe('loras')
expect(assets.value).toEqual(mockAssets)
})
it('accepts ref (backward compatibility)', async () => {
const mockAssets: AssetItem[] = [
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
]
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
}
)
const nodeTypeRef = ref('CheckpointLoaderSimple')
const { category, assets, isLoading } = useAssetWidgetData(nodeTypeRef)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(category.value).toBe('checkpoints')
expect(assets.value).toEqual(mockAssets)
})
it('handles undefined node type gracefully', async () => {
const { category, assets, dropdownItems, isLoading, error } =
useAssetWidgetData(undefined)
await nextTick()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
expect(dropdownItems.value).toEqual([])
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
})
})
})

View File

@@ -34,27 +34,21 @@ export function useAssetWidgetData(
const assets = computed<AssetItem[]>(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? (assetsStore.modelAssetsByNodeType.get(resolvedType) ?? [])
: []
return resolvedType ? (assetsStore.getAssets(resolvedType) ?? []) : []
})
const isLoading = computed(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? (assetsStore.modelLoadingByNodeType.get(resolvedType) ?? false)
: false
return resolvedType ? assetsStore.isModelLoading(resolvedType) : false
})
const error = computed<Error | null>(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? (assetsStore.modelErrorByNodeType.get(resolvedType) ?? null)
: null
return resolvedType ? (assetsStore.getError(resolvedType) ?? null) : null
})
const dropdownItems = computed<DropdownItem[]>(() => {
return assets.value.map((asset) => ({
return (assets.value ?? []).map((asset) => ({
id: asset.id,
name:
(asset.user_metadata?.filename as string | undefined) ?? asset.name,
@@ -71,9 +65,10 @@ export function useAssetWidgetData(
return
}
const hasData = assetsStore.modelAssetsByNodeType.has(currentNodeType)
const isLoading = assetsStore.isModelLoading(currentNodeType)
const hasBeenInitialized = assetsStore.hasAssetKey(currentNodeType)
if (!hasData) {
if (!isLoading && !hasBeenInitialized) {
await assetsStore.updateModelsForNodeType(currentNodeType)
}
},

View File

@@ -0,0 +1,103 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
IBoundingBoxWidget,
IImageCropWidget,
INumericWidget
} from '@/lib/litegraph/src/types/widgets'
import type { Bounds } from '@/renderer/core/layout/types'
import type {
BoundingBoxInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
function isBoundingBoxLikeWidget(
widget: IBaseWidget
): widget is IBoundingBoxWidget | IImageCropWidget {
return widget.type === 'boundingbox' || widget.type === 'imagecrop'
}
function isNumericWidget(widget: IBaseWidget): widget is INumericWidget {
return widget.type === 'number'
}
export const useBoundingBoxWidget = (): ComfyWidgetConstructorV2 => {
return (
node: LGraphNode,
inputSpec: InputSpecV2
): IBoundingBoxWidget | IImageCropWidget => {
const spec = inputSpec as BoundingBoxInputSpec
const { name, component } = spec
const defaultValue: Bounds = spec.default ?? {
x: 0,
y: 0,
width: 512,
height: 512
}
const widgetType = component === 'ImageCrop' ? 'imagecrop' : 'boundingbox'
const fields: (keyof Bounds)[] = ['x', 'y', 'width', 'height']
const subWidgets: INumericWidget[] = []
const rawWidget = node.addWidget(
widgetType,
name,
{ ...defaultValue },
null,
{
serialize: true,
canvasOnly: false
}
)
if (!isBoundingBoxLikeWidget(rawWidget)) {
throw new Error(`Unexpected widget type: ${rawWidget.type}`)
}
const widget = rawWidget
widget.callback = () => {
for (let i = 0; i < fields.length; i++) {
const field = fields[i]
const subWidget = subWidgets[i]
if (subWidget) {
subWidget.value = widget.value[field]
}
}
}
for (const field of fields) {
const subWidget = node.addWidget(
'number',
field,
defaultValue[field],
function (this: INumericWidget, v: number) {
this.value = Math.round(v)
widget.value[field] = this.value
widget.callback?.(widget.value)
},
{
min: field === 'width' || field === 'height' ? 1 : 0,
max: 8192,
step: 10,
step2: 1,
precision: 0,
serialize: false,
canvasOnly: true
}
)
if (!isNumericWidget(subWidget)) {
throw new Error(`Unexpected widget type: ${subWidget.type}`)
}
subWidgets.push(subWidget)
}
widget.linkedWidgets = subWidgets
return widget
}
}

View File

@@ -0,0 +1,490 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { assetService } from '@/platform/assets/services/assetService'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
// Mock factory using actual type
function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'test-asset-id',
name: 'test-image.png',
asset_hash: 'hash123',
size: 1024,
mime_type: 'image/png',
tags: ['input'],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
last_access_time: new Date().toISOString(),
...overrides
}
}
// Use vi.hoisted() to ensure mock state is initialized before mocks
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash))
const mockAssetsStoreState = vi.hoisted(() => {
const inputAssets: AssetItem[] = []
return {
inputAssets,
inputLoading: false
}
})
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockDistributionState.isCloud
}
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: vi.fn(() => ({
get inputAssets() {
return mockAssetsStoreState.inputAssets
},
get inputLoading() {
return mockAssetsStoreState.inputLoading
},
updateInputs: mockUpdateInputs,
getInputName: mockGetInputName
}))
}))
const mockSettingStoreGet = vi.fn(() => false)
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: mockSettingStoreGet
}))
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key: string) =>
key === 'widgets.selectModel' ? 'Select model' : key
)
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
isAssetBrowserEligible: vi.fn(() => false)
}
}))
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => {
const mockAssetBrowserDialogShow = vi.fn()
return {
useAssetBrowserDialog: vi.fn(() => ({
show: mockAssetBrowserDialogShow
}))
}
})
// Test factory functions
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
const mockCallback = vi.fn()
const widget: IBaseWidget = {
type: 'combo',
options: {},
name: 'testWidget',
value: undefined,
callback: mockCallback,
y: 0,
...overrides
}
return widget
}
function createMockNode(comfyClass = 'TestNode'): LGraphNode {
const node = new LGraphNode('TestNode')
node.comfyClass = comfyClass
// Spy on the addWidget method
vi.spyOn(node, 'addWidget').mockImplementation(
(type, name, value, callback) => {
const widget = createMockWidget({ type, name, value })
// Store the callback function on the widget for testing
if (typeof callback === 'function') {
widget.callback = callback
}
return widget
}
)
return node
}
function createMockInputSpec(overrides: Partial<InputSpec> = {}): InputSpec {
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'testInput',
...overrides
}
return inputSpec
}
describe('useComboWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingStoreGet.mockReturnValue(false)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
vi.mocked(useAssetBrowserDialog).mockClear()
mockDistributionState.isCloud = false
mockAssetsStoreState.inputAssets = []
mockAssetsStoreState.inputLoading = false
mockUpdateInputs.mockClear()
})
it('should handle undefined spec', () => {
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode()
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({ name: 'inputName' })
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'inputName',
undefined,
expect.any(Function),
expect.objectContaining({
values: []
})
)
expect(widget).toBe(mockWidget)
})
it('should create normal combo widget when asset API is disabled', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(false) // Asset API disabled
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true) // Widget is eligible
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
options: ['model1.safetensors', 'model2.safetensors']
})
const widget = constructor(mockNode, inputSpec)
expect(widget).toBe(mockWidget)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'ckpt_name',
'model1.safetensors',
expect.any(Function),
{ values: ['model1.safetensors', 'model2.safetensors'] }
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(widget).toBe(mockWidget)
})
it('should create asset browser widget when API enabled', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'model1.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
options: ['model1.safetensors', 'model2.safetensors']
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'model1.safetensors',
expect.any(Function),
expect.any(Object)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
'ckpt_name'
)
expect(widget).toBe(mockWidget)
})
it('should create asset browser widget when default value provided without options', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'fallback.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
default: 'fallback.safetensors'
// Note: no options array provided
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'fallback.safetensors',
expect.any(Function),
expect.any(Object)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(widget).toBe(mockWidget)
})
it('should show Select model when asset widget has undefined current value', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'Select model'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name'
// Note: no default, no options, not remote - getDefaultValue returns undefined
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'Select model', // Should fallback to this instead of undefined
expect.any(Function),
expect.any(Object)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(widget).toBe(mockWidget)
})
describe('cloud input asset mapping', () => {
const HASH_FILENAME =
'72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png'
const HASH_FILENAME_2 =
'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456.jpg'
it.each([
{ nodeClass: 'LoadImage', inputName: 'image' },
{ nodeClass: 'LoadVideo', inputName: 'video' },
{ nodeClass: 'LoadAudio', inputName: 'audio' }
])(
'should create combo widget with getOptionLabel for $nodeClass in cloud',
({ nodeClass, inputName }) => {
mockDistributionState.isCloud = true
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'combo',
name: inputName,
value: HASH_FILENAME
})
const mockNode = createMockNode(nodeClass)
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: inputName,
options: [HASH_FILENAME, HASH_FILENAME_2]
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
inputName,
HASH_FILENAME,
expect.any(Function),
expect.objectContaining({
values: [], // Empty initially, populated dynamically by Proxy
getOptionLabel: expect.any(Function)
})
)
expect(widget).toBe(mockWidget)
}
)
it("should format option labels using store's getInputName function", () => {
mockDistributionState.isCloud = true
mockGetInputName.mockReturnValue('Beautiful Sunset.png')
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'combo',
name: 'image',
value: HASH_FILENAME
})
const mockNode = createMockNode('LoadImage')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
})
constructor(mockNode, inputSpec)
// Extract the injected getOptionLabel function with type narrowing
const addWidgetCall = vi.mocked(mockNode.addWidget).mock.calls[0]
const options = addWidgetCall[4]
if (typeof options !== 'object' || !options) {
throw new Error('Expected options to be an object')
}
if (!('getOptionLabel' in options)) {
throw new Error('Expected options to have getOptionLabel property')
}
if (typeof options.getOptionLabel !== 'function') {
throw new Error('Expected getOptionLabel to be a function')
}
// Test that the injected function calls getInputName
const result = options.getOptionLabel(HASH_FILENAME)
expect(mockGetInputName).toHaveBeenCalledWith(HASH_FILENAME)
expect(result).toBe('Beautiful Sunset.png')
})
it('should create normal combo widget for non-input nodes in cloud', () => {
mockDistributionState.isCloud = true
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode('SomeOtherNode')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'option',
options: [HASH_FILENAME, HASH_FILENAME_2]
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'option',
HASH_FILENAME,
expect.any(Function),
{ values: [HASH_FILENAME, HASH_FILENAME_2] }
)
expect(widget).toBe(mockWidget)
})
it('should create normal combo widget for LoadImage in OSS', () => {
mockDistributionState.isCloud = false
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode('LoadImage')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME, HASH_FILENAME_2]
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'image',
HASH_FILENAME,
expect.any(Function),
{
values: [HASH_FILENAME, HASH_FILENAME_2]
}
)
expect(widget).toBe(mockWidget)
})
it('should trigger lazy load for cloud input nodes', () => {
mockDistributionState.isCloud = true
mockAssetsStoreState.inputAssets = []
mockAssetsStoreState.inputLoading = false
const constructor = useComboWidget()
const mockWidget = createMockWidget({ type: 'combo' })
const mockNode = createMockNode('LoadImage')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
})
constructor(mockNode, inputSpec)
expect(mockUpdateInputs).toHaveBeenCalledTimes(1)
})
it('should not trigger lazy load if assets already loading', () => {
mockDistributionState.isCloud = true
mockAssetsStoreState.inputAssets = []
mockAssetsStoreState.inputLoading = true
const constructor = useComboWidget()
const mockWidget = createMockWidget({ type: 'combo' })
const mockNode = createMockNode('LoadImage')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
})
constructor(mockNode, inputSpec)
expect(mockUpdateInputs).not.toHaveBeenCalled()
})
it('should not trigger lazy load if assets already loaded', () => {
mockDistributionState.isCloud = true
mockAssetsStoreState.inputAssets = [
createMockAssetItem({
id: 'asset-123',
name: 'image1.png',
asset_hash: HASH_FILENAME
})
]
mockAssetsStoreState.inputLoading = false
const constructor = useComboWidget()
const mockWidget = createMockWidget({ type: 'combo' })
const mockNode = createMockNode('LoadImage')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
})
constructor(mockNode, inputSpec)
expect(mockUpdateInputs).not.toHaveBeenCalled()
})
})
})

View File

@@ -4,25 +4,29 @@ import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isAssetWidget, isComboWidget } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
IWidgetAssetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import {
assetFilenameSchema,
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { assetService } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
ComboInputSpec,
InputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { addValueControlWidgets } from '@/scripts/widgets'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { addValueControlWidgets } from '@/scripts/widgets'
import { useAssetsStore } from '@/stores/assetsStore'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
@@ -69,6 +73,16 @@ const addMultiSelectWidget = (
addWidget(node, widget as BaseDOMWidget<object | string>)
// TODO: Add remote support to multi-select widget
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
if (inputSpec.control_after_generate) {
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
'fixed',
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget
}
@@ -81,55 +95,59 @@ const createAssetBrowserWidget = (
const displayLabel = currentValue ?? t('widgets.selectModel')
const assetBrowserDialog = useAssetBrowserDialog()
async function openModal(widget: IBaseWidget) {
if (!isAssetWidget(widget)) {
throw new Error(`Expected asset widget but received ${widget.type}`)
}
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}
const filename = getAssetFilename(validatedAsset.data)
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}
const oldValue = widget.value
widget.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
const options: IWidgetAssetOptions = { openModal }
const widget = node.addWidget(
'asset',
inputSpec.name,
displayLabel,
async function (this: IBaseWidget) {
if (!isAssetWidget(widget)) {
throw new Error(`Expected asset widget but received ${widget.type}`)
}
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}
const filename = validatedAsset.data.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}
const oldValue = widget.value
this.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
() => undefined,
options
)
return widget

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { _for_testing } from '@/renderer/extensions/vueNodes/widgets/composables/useFloatWidget'
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
settings: {}
})
}))
const { onFloatValueChange } = _for_testing
describe('useFloatWidget', () => {
describe('onFloatValueChange', () => {
let widget: any
beforeEach(() => {
// Reset the widget before each test
widget = {
options: {},
value: 0
}
})
it('should not round values when round option is not set', () => {
widget.options.round = undefined
onFloatValueChange.call(widget, 5.7)
expect(widget.value).toBe(5.7)
})
it('should round values based on round option', () => {
widget.options.round = 0.5
onFloatValueChange.call(widget, 5.7)
expect(widget.value).toBe(5.5)
widget.options.round = 0.1
onFloatValueChange.call(widget, 5.74)
expect(widget.value).toBe(5.7)
widget.options.round = 1
onFloatValueChange.call(widget, 5.7)
expect(widget.value).toBe(6)
})
it('should respect min and max constraints after rounding', () => {
widget.options.round = 0.5
widget.options.min = 1
widget.options.max = 5
// Should round to 1 and respect min
onFloatValueChange.call(widget, 0.7)
expect(widget.value).toBe(1)
// Should round to 5.5 but be clamped to max of 5
onFloatValueChange.call(widget, 5.3)
expect(widget.value).toBe(5)
// Should round to 3.5 and be within bounds
onFloatValueChange.call(widget, 3.6)
expect(widget.value).toBe(3.5)
})
it('should handle Number.EPSILON for precision issues', () => {
widget.options.round = 0.1
// Without Number.EPSILON, 1.35 / 0.1 = 13.499999999999998
// which would round to 13 * 0.1 = 1.3 instead of 1.4
onFloatValueChange.call(widget, 1.35)
expect(widget.value).toBeCloseTo(1.4, 10)
// Test another edge case
onFloatValueChange.call(widget, 2.95)
expect(widget.value).toBeCloseTo(3, 10)
})
})
})

View File

@@ -6,6 +6,8 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { isFloatInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { addValueControlWidget } from '@/scripts/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
function onFloatValueChange(this: INumericWidget, v: number) {
const round = this.options.round
@@ -55,7 +57,7 @@ export const useFloatWidget = () => {
/** Assertion {@link inputSpec.default} */
const defaultValue = (inputSpec.default as number | undefined) ?? 0
return node.addWidget(
const widget = node.addWidget(
widgetType,
inputSpec.name,
defaultValue,
@@ -73,6 +75,20 @@ export const useFloatWidget = () => {
precision
}
)
if (inputSpec.control_after_generate) {
const controlWidget = addValueControlWidget(
node,
widget,
'fixed',
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
widget.linkedWidgets = [controlWidget]
}
return widget
}
return widgetConstructor

View File

@@ -12,6 +12,37 @@ import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
/**
* Workaround for Chrome GPU bug:
* When Chrome is maximized with GPU acceleration and high DPR, calling
* drawImage(canvas) + drawImage(img) in the same frame causes severe
* performance degradation (FPS drops to 2-10, memory spikes ~18GB).
*
* Solution: Defer image rendering using queueMicrotask to separate
* the two drawImage calls into different tasks.
*
* Note: As tested, requestAnimationFrame delays rendering to the next frame,
* causing visible image flickering. queueMicrotask executes within the same
* frame, avoiding flicker while still separating the drawImage calls.
*/
let deferredImageRenders: Array<() => void> = []
let deferredRenderScheduled = false
function scheduleDeferredImageRender() {
if (deferredRenderScheduled) return
deferredRenderScheduled = true
queueMicrotask(() => {
const renders = deferredImageRenders
deferredImageRenders = []
deferredRenderScheduled = false
for (const render of renders) {
render()
}
})
}
const renderPreview = (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
@@ -124,13 +155,31 @@ const renderPreview = (
const imgWidth = ratio * img.width
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
ctx.drawImage(
// Defer image rendering to work around Chrome GPU bug
const transform = ctx.getTransform()
const filter = ctx.filter
const drawParams = {
img,
imgX + cell_padding,
imgY + cell_padding,
imgWidth - cell_padding * 2,
imgHeight - cell_padding * 2
)
x: imgX + cell_padding,
y: imgY + cell_padding,
w: imgWidth - cell_padding * 2,
h: imgHeight - cell_padding * 2
}
deferredImageRenders.push(() => {
ctx.save()
ctx.setTransform(transform)
ctx.filter = filter
ctx.drawImage(
drawParams.img,
drawParams.x,
drawParams.y,
drawParams.w,
drawParams.h
)
ctx.restore()
})
scheduleDeferredImageRender()
if (!compact_mode) {
// rectangle cell and border line style
ctx.strokeStyle = '#8F8F8F'
@@ -167,7 +216,16 @@ const renderPreview = (
const x = (dw - w) / 2
const y = (dh - h) / 2 + shiftY
ctx.drawImage(img, x, y, w, h)
// Defer image rendering to work around Chrome GPU bug
const transform = ctx.getTransform()
deferredImageRenders.push(() => {
ctx.save()
ctx.setTransform(transform)
ctx.drawImage(img, x, y, w, h)
ctx.restore()
})
scheduleDeferredImageRender()
// Draw image size text below the image
if (allowImageSizeDraw) {
@@ -207,14 +265,19 @@ const renderPreview = (
}
}
ctx.fillStyle = fill
ctx.beginPath()
ctx.roundRect(x, y, sz, sz, [4])
ctx.fill()
ctx.fillStyle = textFill
ctx.font = '12px Arial'
ctx.textAlign = 'center'
ctx.fillText(text, x + 15, y + 20)
deferredImageRenders.push(() => {
ctx.save()
ctx.setTransform(transform)
ctx.fillStyle = fill
ctx.beginPath()
ctx.roundRect(x, y, sz, sz, [4])
ctx.fill()
ctx.fillStyle = textFill
ctx.font = '12px Inter, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(text, x + 15, y + 20)
ctx.restore()
})
return isClicking
}
@@ -305,7 +368,8 @@ export const useImagePreviewWidget = () => {
) => {
return node.addCustomWidget(
new ImagePreviewWidget(node, inputSpec.name, {
serialize: false
serialize: false,
canvasOnly: true
})
)
}

View File

@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { _for_testing } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget'
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
settings: {}
})
}))
const { onValueChange } = _for_testing
describe('useIntWidget', () => {
describe('onValueChange', () => {
let widget: any
beforeEach(() => {
// Reset the widget before each test
widget = {
options: {},
value: 0
}
})
it('should round values based on step size', () => {
widget.options.step2 = 0.1
onValueChange.call(widget, 5.7)
expect(widget.value).toBe(5.7)
widget.options.step2 = 0.5
onValueChange.call(widget, 7.3)
expect(widget.value).toBe(7.5)
widget.options.step2 = 1
onValueChange.call(widget, 23.4)
expect(widget.value).toBe(23)
})
it('should handle undefined step by using default of 1', () => {
widget.options.step2 = undefined
onValueChange.call(widget, 3.7)
expect(widget.value).toBe(4)
})
it('should account for min value offset', () => {
widget.options.step2 = 2
widget.options.min = 1
// 2 valid values between 1.6 are 1 and 3
// 1.6 is closer to 1, so it should round to 1
onValueChange.call(widget, 1.6)
expect(widget.value).toBe(1)
})
it('should handle undefined min by using default of 0', () => {
widget.options.step2 = 2
widget.options.min = undefined
onValueChange.call(widget, 5.7)
expect(widget.value).toBe(6)
})
it('should handle NaN shift value', () => {
widget.options.step2 = 0
widget.options.min = 1
onValueChange.call(widget, 5.7)
expect(widget.value).toBe(6)
})
})
})

View File

@@ -1,11 +1,11 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { addValueControlWidget } from '@/scripts/widgets'
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { addValueControlWidget } from '@/scripts/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
function onValueChange(this: INumericWidget, v: number) {
// For integers, always round to the nearest step
@@ -69,14 +69,10 @@ export const useIntWidget = () => {
const controlAfterGenerate =
inputSpec.control_after_generate ??
/**
* Compatibility with legacy node convention. Int input with name
* 'seed' or 'noise_seed' get automatically added a control widget.
*/
['seed', 'noise_seed'].includes(inputSpec.name)
if (controlAfterGenerate) {
const seedControl = addValueControlWidget(
const controlWidget = addValueControlWidget(
node,
widget,
'randomize',
@@ -84,7 +80,7 @@ export const useIntWidget = () => {
undefined,
transformInputSpecV2ToV1(inputSpec)
)
widget.linkedWidgets = [seedControl]
widget.linkedWidgets = [controlWidget]
}
return widget

View File

@@ -0,0 +1,45 @@
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
interface NumberWidgetOptions {
step?: number
step2?: number
precision?: number
}
/**
* Shared composable for calculating step values in number input widgets
* Handles both explicit step2 values and precision-derived steps
*/
export function useNumberStepCalculation(
options: NumberWidgetOptions | undefined,
precisionArg: MaybeRefOrGetter<number | undefined>,
returnUndefinedForDefault = false
) {
return computed(() => {
const precision = toValue(precisionArg)
// Use step2 (correct input spec value) if available
if (options?.step2 !== undefined) {
return Number(options.step2)
}
// Use step / 10 for custom large step values (> 10) to match litegraph behavior
// This is important for extensions like Impact Pack that use custom step values (e.g., 640)
// We skip default step values (1, 10) to avoid affecting normal widgets
const step = options?.step
if (step !== undefined && step > 10) {
return Number(step) / 10
}
if (precision === undefined) {
return returnUndefinedForDefault ? undefined : 0
}
if (precision === 0) return 1
// For precision > 0, step = 1 / (10^precision)
const calculatedStep = 1 / Math.pow(10, precision)
return returnUndefinedForDefault
? calculatedStep
: Number(calculatedStep.toFixed(precision))
})
}

View File

@@ -0,0 +1,746 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { IWidget } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useRemoteWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
const createMockNode = (overrides: Partial<LGraphNode> = {}): LGraphNode => {
const node = new LGraphNode('TestNode')
Object.assign(node, overrides)
return node
}
const createMockWidget = (overrides = {}): IWidget =>
({ ...overrides }) as unknown as IWidget
const mockCloudAuth = vi.hoisted(() => ({
isCloud: false,
authHeader: null as { Authorization: string } | null
}))
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof axios>()
return {
default: {
...actual,
get: vi.fn()
}
}
})
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockCloudAuth.isCloud
}
}))
vi.mock('@/stores/firebaseAuthStore', async () => {
return {
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader: vi.fn(() => Promise.resolve(mockCloudAuth.authHeader))
}))
}
})
vi.mock('@/platform/settings/settingStore', async () => {
return {
useSettingStore: () => ({
settings: {}
})
}
})
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
const DEFAULT_VALUE = 'Loading...'
function createMockConfig(overrides = {}): RemoteWidgetConfig {
return {
route: `/api/test/${Date.now()}${Math.random().toString(36).substring(2, 15)}`,
refresh: 0,
...overrides
}
}
const createMockOptions = (inputOverrides = {}) => ({
remoteConfig: createMockConfig(inputOverrides),
defaultValue: DEFAULT_VALUE,
node: createMockNode(),
widget: createMockWidget()
})
function mockAxiosResponse(data: unknown, status = 200) {
vi.mocked(axios.get).mockResolvedValueOnce({ data, status })
}
function mockAxiosError(error: Error | string) {
const err = error instanceof Error ? error : new Error(error)
vi.mocked(axios.get).mockRejectedValueOnce(err)
}
function createHookWithData(data: unknown, inputOverrides = {}) {
mockAxiosResponse(data)
const hook = useRemoteWidget(createMockOptions(inputOverrides))
return hook
}
async function setupHookWithResponse(data: unknown, inputOverrides = {}) {
const hook = createHookWithData(data, inputOverrides)
const result = await getResolvedValue(hook)
return { hook, result }
}
async function getResolvedValue(hook: ReturnType<typeof useRemoteWidget>) {
// Create a promise that resolves when the fetch is complete
const responsePromise = new Promise<void>((resolve) => {
hook.getValue(() => resolve())
})
await responsePromise
return hook.getCachedValue()
}
describe('useRemoteWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset mocks
vi.mocked(axios.get).mockReset()
// Reset cache between tests
vi.spyOn(Map.prototype, 'get').mockClear()
vi.spyOn(Map.prototype, 'set').mockClear()
vi.spyOn(Map.prototype, 'delete').mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('initialization', () => {
it('should create hook with default values', () => {
const hook = useRemoteWidget(createMockOptions())
expect(hook.getCachedValue()).toBeUndefined()
expect(hook.getValue()).toBe('Loading...')
})
it('should generate consistent cache keys', () => {
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
expect(hook1.cacheKey).toBe(hook2.cacheKey)
})
it('should handle query params in cache key', () => {
const hook1 = useRemoteWidget(
createMockOptions({ query_params: { a: 1 } })
)
const hook2 = useRemoteWidget(
createMockOptions({ query_params: { a: 2 } })
)
expect(hook1.cacheKey).not.toBe(hook2.cacheKey)
})
})
describe('fetchOptions', () => {
it('should fetch data successfully', async () => {
const mockData = ['optionA', 'optionB']
const { hook, result } = await setupHookWithResponse(mockData)
expect(result).toEqual(mockData)
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
hook.cacheKey.split(';')[0], // Get the route part from cache key
expect.any(Object)
)
})
it('should use response_key if provided', async () => {
const mockResponse = { items: ['optionB', 'optionA', 'optionC'] }
const { result } = await setupHookWithResponse(mockResponse, {
response_key: 'items'
})
expect(result).toEqual(mockResponse.items)
})
it('should cache successful responses', async () => {
const mockData = ['optionA', 'optionB', 'optionC', 'optionD']
const { hook } = await setupHookWithResponse(mockData)
const entry = hook.getCacheEntry()
expect(entry?.data).toEqual(mockData)
expect(entry?.error).toBeNull()
})
it('should handle fetch errors', async () => {
const error = new Error('Network error')
mockAxiosError(error)
const { hook } = await setupHookWithResponse([])
const entry = hook.getCacheEntry()
expect(entry?.error).toBeTruthy()
expect(entry?.lastErrorTime).toBeDefined()
})
it('should handle empty array responses', async () => {
const { result } = await setupHookWithResponse([])
expect(result).toEqual([])
})
it('should handle malformed response data', async () => {
const hook = useRemoteWidget(createMockOptions())
mockAxiosResponse(null)
const data1 = hook.getValue()
mockAxiosResponse(undefined)
const data2 = hook.getValue()
expect(data1).toBe(DEFAULT_VALUE)
expect(data2).toBe(DEFAULT_VALUE)
})
it('should handle non-200 status codes', async () => {
mockAxiosError('Request failed with status code 404')
const { hook } = await setupHookWithResponse([])
const entry = hook.getCacheEntry()
expect(entry?.error?.message).toBe('Request failed with status code 404')
})
})
describe('refresh behavior', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
describe('permanent widgets (no refresh)', () => {
it('permanent widgets should not attempt fetch after initialization', async () => {
const mockData = ['data that is permanent after initialization']
const { hook } = await setupHookWithResponse(mockData)
await getResolvedValue(hook)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('permanent widgets should re-fetch if refreshValue is called', async () => {
const mockData = ['data that is permanent after initialization']
const { hook } = await setupHookWithResponse(mockData)
await getResolvedValue(hook)
expect(hook.getCachedValue()).toEqual(mockData)
const refreshedData = ['data that user forced to be fetched']
mockAxiosResponse(refreshedData)
hook.refreshValue()
// Wait for cache to update with refreshed data
await vi.waitFor(() => {
expect(hook.getCachedValue()).toEqual(refreshedData)
})
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('permanent widgets should still retry if request fails', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
const secondData = await getResolvedValue(hook)
expect(secondData).toBe('Loading...')
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('should treat empty refresh field as permanent', async () => {
const { hook } = await setupHookWithResponse(['data that is permanent'])
await getResolvedValue(hook)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
})
it('should refresh when data is stale', async () => {
const refresh = 256
const mockData1 = ['option1']
const mockData2 = ['option2']
const { hook } = await setupHookWithResponse(mockData1, { refresh })
mockAxiosResponse(mockData2)
vi.setSystemTime(Date.now() + refresh)
const newData = await getResolvedValue(hook)
expect(newData).toEqual(mockData2)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('should not refresh when data is not stale', async () => {
const { hook } = await setupHookWithResponse(['option1'], {
refresh: 512
})
vi.setSystemTime(Date.now() + 128)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('should use backoff instead of refresh after error', async () => {
const refresh = 4096
const { hook } = await setupHookWithResponse(['first success'], {
refresh
})
mockAxiosError('Network error')
vi.setSystemTime(Date.now() + refresh)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
mockAxiosResponse(['second success'])
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
const thirdData = await getResolvedValue(hook)
expect(thirdData).toEqual(['second success'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(3)
})
it('should use last valid value after error', async () => {
const refresh = 4096
const { hook } = await setupHookWithResponse(['a valid value'], {
refresh
})
mockAxiosError('Network error')
vi.setSystemTime(Date.now() + refresh)
const secondData = await getResolvedValue(hook)
expect(secondData).toEqual(['a valid value'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
})
describe('error handling and backoff', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should implement exponential backoff on errors', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
vi.setSystemTime(Date.now() + 500)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1) // Still backing off
vi.setSystemTime(Date.now() + 3000)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
expect(entry1?.data).toBeDefined()
})
it('should reset error state on successful fetch', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
const firstData = await getResolvedValue(hook)
expect(firstData).toBe('Loading...')
vi.setSystemTime(Date.now() + 3000)
mockAxiosResponse(['option1'])
const secondData = await getResolvedValue(hook)
expect(secondData).toEqual(['option1'])
const entry = hook.getCacheEntry()
expect(entry?.error).toBeNull()
expect(entry?.retryCount).toBe(0)
})
it('should save successful data after backoff', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
vi.setSystemTime(Date.now() + 3000)
mockAxiosResponse(['success after backoff'])
const secondData = await getResolvedValue(hook)
expect(secondData).toEqual(['success after backoff'])
const entry2 = hook.getCacheEntry()
expect(entry2?.error).toBeNull()
expect(entry2?.retryCount).toBe(0)
})
it('should save successful data after multiple backoffs', async () => {
mockAxiosError('Network error')
mockAxiosError('Network error')
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
vi.setSystemTime(Date.now() + 3000)
const secondData = await getResolvedValue(hook)
expect(secondData).toBe('Loading...')
expect(entry1?.error).toBeDefined()
vi.setSystemTime(Date.now() + 9000)
const thirdData = await getResolvedValue(hook)
expect(thirdData).toBe('Loading...')
expect(entry1?.error).toBeDefined()
vi.setSystemTime(Date.now() + 120_000)
mockAxiosResponse(['success after multiple backoffs'])
const fourthData = await getResolvedValue(hook)
expect(fourthData).toEqual(['success after multiple backoffs'])
const entry2 = hook.getCacheEntry()
expect(entry2?.error).toBeNull()
expect(entry2?.retryCount).toBe(0)
})
})
describe('cache management', () => {
it('should clear cache entries', async () => {
const { hook } = await setupHookWithResponse(['to be cleared'])
expect(hook.getCachedValue()).toBeDefined()
hook.refreshValue()
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
})
it('should prevent duplicate in-flight requests', async () => {
const mockData = ['non-duplicate']
mockAxiosResponse(mockData)
const hook = useRemoteWidget(createMockOptions())
// Start two concurrent getValue calls
const promise1 = new Promise<void>((resolve) => {
hook.getValue(() => resolve())
})
const promise2 = new Promise<void>((resolve) => {
hook.getValue(() => resolve())
})
// Wait for both e
await Promise.all([promise1, promise2])
// Both should see the same cached data
expect(hook.getCachedValue()).toEqual(mockData)
// Only one axios call should have been made
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
})
describe('concurrent access and multiple instances', () => {
it('should handle concurrent hook instances with same route', async () => {
mockAxiosResponse(['shared data'])
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
// Since they have the same route, only one request will be made
await Promise.race([getResolvedValue(hook1), getResolvedValue(hook2)])
const data1 = hook1.getValue()
const data2 = hook2.getValue()
expect(data1).toEqual(['shared data'])
expect(data2).toEqual(['shared data'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
})
it('should use shared cache across multiple hooks', async () => {
mockAxiosResponse(['shared data'])
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
const hook3 = useRemoteWidget(options)
const hook4 = useRemoteWidget(options)
const data1 = await getResolvedValue(hook1)
const data2 = await getResolvedValue(hook2)
const data3 = await getResolvedValue(hook3)
const data4 = await getResolvedValue(hook4)
expect(data1).toEqual(['shared data'])
expect(data2).toBe(data1)
expect(data3).toBe(data1)
expect(data4).toBe(data1)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
expect(hook2.getCachedValue()).toBe(hook3.getCachedValue())
expect(hook3.getCachedValue()).toBe(hook4.getCachedValue())
})
it('should handle rapid cache clearing during fetch', async () => {
let resolvePromise: (value: any) => void
const delayedPromise = new Promise((resolve) => {
resolvePromise = resolve
})
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise as any)
const hook = useRemoteWidget(createMockOptions())
hook.getValue()
hook.refreshValue()
resolvePromise!({ data: ['delayed data'] })
const data = await getResolvedValue(hook)
// The value should be the default value because the refreshValue
// clears the cache and the fetch is aborted
expect(data).toEqual(DEFAULT_VALUE)
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
})
it('should handle widget destroyed during fetch', async () => {
let resolvePromise: (value: any) => void
const delayedPromise = new Promise((resolve) => {
resolvePromise = resolve
})
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise as any)
let hook = useRemoteWidget(createMockOptions())
const fetchPromise = hook.getValue()
hook = null as any
resolvePromise!({ data: ['delayed data'] })
await fetchPromise
expect(hook).toBeNull()
hook = useRemoteWidget(createMockOptions())
const data2 = await getResolvedValue(hook)
expect(data2).toEqual(DEFAULT_VALUE)
})
})
describe('cloud distribution authentication', () => {
describe('when distribution is cloud', () => {
describe('when authenticated', () => {
it('passes Firebase authentication token in request headers', async () => {
const mockData = ['authenticated data']
mockCloudAuth.authHeader = null
mockCloudAuth.isCloud = true
mockCloudAuth.authHeader = { Authorization: 'Bearer test-token' }
mockAxiosResponse(mockData)
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: { Authorization: 'Bearer test-token' }
})
)
})
})
})
describe('when distribution is not cloud', () => {
it('bypasses authentication for non-cloud environments', async () => {
const mockData = ['non-cloud data']
mockCloudAuth.isCloud = false
mockAxiosResponse(mockData)
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const axiosCall = vi.mocked(axios.get).mock.calls[0][1]
expect(axiosCall).not.toHaveProperty('headers')
})
})
})
describe('auto-refresh on task completion', () => {
it('should add auto-refresh toggle widget', () => {
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {
refresh: vi.fn()
}
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
})
// Should add auto-refresh toggle widget
expect(mockNode.addWidget).toHaveBeenCalledWith(
'toggle',
'Auto-refresh after generation',
false,
expect.any(Function),
{
serialize: false
}
)
})
it('should register event listener when enabled', async () => {
const { api } = await import('@/scripts/api')
const addEventListenerSpy = vi.spyOn(api, 'addEventListener')
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {
refresh: vi.fn()
}
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
})
// Event listener should be registered immediately
expect(addEventListenerSpy).toHaveBeenCalledWith(
'execution_success',
expect.any(Function)
)
})
it('should refresh widget when workflow completes successfully', async () => {
const { api } = await import('@/scripts/api')
let executionSuccessHandler: (() => void) | undefined
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {} as any
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget
})
// Spy on the refresh function that was added by useRemoteWidget
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
// Get the toggle callback and enable auto-refresh
const toggleCallback = mockNode.addWidget.mock.calls.find(
(call) => call[0] === 'toggle'
)?.[3]
toggleCallback?.(true)
// Simulate workflow completion
executionSuccessHandler?.()
expect(refreshSpy).toHaveBeenCalled()
})
it('should not refresh when toggle is disabled', async () => {
const { api } = await import('@/scripts/api')
let executionSuccessHandler: (() => void) | undefined
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {} as any
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget
})
// Spy on the refresh function that was added by useRemoteWidget
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
// Toggle is disabled by default
// Simulate workflow completion
executionSuccessHandler?.()
expect(refreshSpy).not.toHaveBeenCalled()
})
it('should cleanup event listener on node removal', async () => {
const { api } = await import('@/scripts/api')
let executionSuccessHandler: (() => void) | undefined
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const removeEventListenerSpy = vi.spyOn(api, 'removeEventListener')
const mockNode = {
addWidget: vi.fn(),
widgets: [],
onRemoved: undefined as any
}
const mockWidget = {
refresh: vi.fn()
}
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
})
// Simulate node removal
mockNode.onRemoved?.()
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'execution_success',
executionSuccessHandler
)
})
})
})

View File

@@ -10,6 +10,7 @@ import { app } from '@/scripts/app'
export const DEFAULT_VIDEO_WIDTH = 640
export const DEFAULT_VIDEO_HEIGHT = 480
// knip-ignore-exports UseWebcamCaptureOptions, UseWebcamCaptureReturn
export interface UseWebcamCaptureOptions {
videoRef: Ref<HTMLVideoElement | undefined>
readonly?: boolean

View File

@@ -0,0 +1,184 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getComponent,
isEssential,
shouldRenderAsVue,
FOR_TESTING
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
const {
WidgetButton,
WidgetColorPicker,
WidgetInputNumber,
WidgetInputText,
WidgetMarkdown,
WidgetSelect,
WidgetTextarea,
WidgetToggleSwitch
} = FOR_TESTING
vi.mock('@/stores/queueStore', () => ({
useQueueStore: vi.fn(() => ({
historyTasks: []
}))
}))
// Mock the settings store for components that might use it
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => 'before')
})
}))
describe('widgetRegistry', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
})
describe('getComponent', () => {
// Test number type mappings
describe('number types', () => {
it('should map int types to slider widget', () => {
expect(getComponent('int')).toBe(WidgetInputNumber)
expect(getComponent('INT')).toBe(WidgetInputNumber)
})
it('should map float types to slider widget', () => {
expect(getComponent('float')).toBe(WidgetInputNumber)
expect(getComponent('FLOAT')).toBe(WidgetInputNumber)
expect(getComponent('number')).toBe(WidgetInputNumber)
expect(getComponent('slider')).toBe(WidgetInputNumber)
})
})
// Test text type mappings
describe('text types', () => {
it('should map text variations to input text widget', () => {
expect(getComponent('text')).toBe(WidgetInputText)
expect(getComponent('string')).toBe(WidgetInputText)
expect(getComponent('STRING')).toBe(WidgetInputText)
})
it('should map multiline text types to textarea widget', () => {
expect(getComponent('multiline')).toBe(WidgetTextarea)
expect(getComponent('textarea')).toBe(WidgetTextarea)
expect(getComponent('TEXTAREA')).toBe(WidgetTextarea)
expect(getComponent('customtext')).toBe(WidgetTextarea)
})
it('should map markdown to markdown widget', () => {
expect(getComponent('MARKDOWN')).toBe(WidgetMarkdown)
expect(getComponent('markdown')).toBe(WidgetMarkdown)
})
})
// Test selection type mappings
describe('selection types', () => {
it('should map combo types to select widget', () => {
expect(getComponent('combo')).toBe(WidgetSelect)
expect(getComponent('COMBO')).toBe(WidgetSelect)
})
})
// Test boolean type mappings
describe('boolean types', () => {
it('should map boolean types to toggle switch widget', () => {
expect(getComponent('toggle')).toBe(WidgetToggleSwitch)
expect(getComponent('boolean')).toBe(WidgetToggleSwitch)
expect(getComponent('BOOLEAN')).toBe(WidgetToggleSwitch)
})
})
// Test advanced widget mappings
describe('advanced widgets', () => {
it('should map color types to color picker widget', () => {
expect(getComponent('color')).toBe(WidgetColorPicker)
expect(getComponent('COLOR')).toBe(WidgetColorPicker)
})
it('should map button types to button widget', () => {
expect(getComponent('button')).toBe(WidgetButton)
expect(getComponent('BUTTON')).toBe(WidgetButton)
})
})
// Test fallback behavior
describe('fallback behavior', () => {
it('should return null for unknown types', () => {
expect(getComponent('unknown')).toBe(null)
expect(getComponent('custom_widget')).toBe(null)
expect(getComponent('')).toBe(null)
})
})
})
describe('shouldRenderAsVue', () => {
it('should return false for widgets marked as canvas-only', () => {
const widget = { type: 'text', options: { canvasOnly: true } }
expect(shouldRenderAsVue(widget)).toBe(false)
})
it('should return false for widgets without a type', () => {
const widget = { options: {} }
expect(shouldRenderAsVue(widget)).toBe(false)
})
it('should return true for widgets with mapped types', () => {
expect(shouldRenderAsVue({ type: 'text' })).toBe(true)
expect(shouldRenderAsVue({ type: 'int' })).toBe(true)
expect(shouldRenderAsVue({ type: 'combo' })).toBe(true)
})
it('should respect options while checking type', () => {
const widget: Partial<SafeWidgetData> = {
type: 'text',
options: { precision: 5 }
}
expect(shouldRenderAsVue(widget)).toBe(true)
})
})
describe('isEssential', () => {
it('should identify essential widget types', () => {
expect(isEssential('int')).toBe(true)
expect(isEssential('INT')).toBe(true)
expect(isEssential('float')).toBe(true)
expect(isEssential('FLOAT')).toBe(true)
expect(isEssential('boolean')).toBe(true)
expect(isEssential('BOOLEAN')).toBe(true)
expect(isEssential('combo')).toBe(true)
expect(isEssential('COMBO')).toBe(true)
})
it('should identify non-essential widget types', () => {
expect(isEssential('button')).toBe(false)
expect(isEssential('color')).toBe(false)
expect(isEssential('chart')).toBe(false)
expect(isEssential('fileupload')).toBe(false)
})
it('should return false for unknown types', () => {
expect(isEssential('unknown')).toBe(false)
expect(isEssential('')).toBe(false)
})
})
describe('edge cases', () => {
it('should handle widgets with empty options', () => {
const widget = { type: 'text', options: {} }
expect(shouldRenderAsVue(widget)).toBe(true)
})
it('should handle case sensitivity correctly through aliases', () => {
// Test that both lowercase and uppercase work
expect(getComponent('string')).toBe(WidgetInputText)
expect(getComponent('STRING')).toBe(WidgetInputText)
expect(getComponent('combo')).toBe(WidgetSelect)
expect(getComponent('COMBO')).toBe(WidgetSelect)
})
})
})