Compare commits

..

2 Commits

Author SHA1 Message Date
bymyself
dc86d627cb fix: use menuitem role instead of menuitemradio for queue mode menu
DropdownMenuItem from reka-ui renders with role='menuitem', not
'menuitemradio'. The failing tests used the wrong ARIA role selector.
2026-04-13 23:17:31 +00:00
bymyself
b5ad30f934 test: add E2E tests for queue button modes 2026-04-13 22:58:45 +00:00
24 changed files with 1735 additions and 2743 deletions

View File

@@ -0,0 +1,80 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Queue button modes', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
test('Run button is visible in topbar', async ({ comfyPage }) => {
await expect(comfyPage.runButton).toBeVisible()
})
test('Queue mode trigger menu is visible', async ({ comfyPage }) => {
const trigger = comfyPage.page.getByTestId(
TestIds.topbar.queueModeMenuTrigger
)
await expect(trigger).toBeVisible()
})
test('Clicking queue mode trigger opens mode menu', async ({ comfyPage }) => {
const trigger = comfyPage.page.getByTestId(
TestIds.topbar.queueModeMenuTrigger
)
await trigger.click()
const menu = comfyPage.page.getByRole('menu')
await expect(menu).toBeVisible()
})
test('Queue mode menu shows available modes', async ({ comfyPage }) => {
const trigger = comfyPage.page.getByTestId(
TestIds.topbar.queueModeMenuTrigger
)
await trigger.click()
const menu = comfyPage.page.getByRole('menu')
await expect(menu).toBeVisible()
await expect(menu.getByRole('menuitem').first()).toBeVisible()
})
test('Queue mode menu closes after selecting a mode', async ({
comfyPage
}) => {
const trigger = comfyPage.page.getByTestId(
TestIds.topbar.queueModeMenuTrigger
)
await trigger.click()
const menu = comfyPage.page.getByRole('menu')
await expect(menu).toBeVisible()
const firstItem = menu.getByRole('menuitem').first()
await firstItem.click()
await expect(menu).toBeHidden()
})
test('Run button sends prompt when clicked', async ({ comfyPage }) => {
let promptQueued = false
await comfyPage.page.route('**/api/prompt', async (route) => {
promptQueued = true
await route.fulfill({
status: 200,
body: JSON.stringify({
prompt_id: 'test-id',
number: 1,
node_errors: {}
})
})
})
await comfyPage.runButton.click()
await expect.poll(() => promptQueued).toBe(true)
})
})

View File

@@ -23,7 +23,7 @@ See `docs/testing/*.md` for detailed patterns.
## Component Testing
- Use `@testing-library/vue` with `@testing-library/user-event` for component tests (an ESLint rule bans `@vue/test-utils` in new tests)
- Use Vue Test Utils for component tests
- Follow advice about making components easy to test
- Wait for reactivity with `await nextTick()` after state changes

View File

@@ -31,7 +31,7 @@ Our tests use the following frameworks and libraries:
- [Vitest](https://vitest.dev/) - Test runner and assertion library
- [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) - Preferred for user-centric component testing
- [@testing-library/user-event](https://testing-library.com/docs/user-event/intro/) - Realistic user interaction simulation
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities (legacy; new tests must use @testing-library/vue)
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities (also accepted)
- [Pinia](https://pinia.vuejs.org/cookbook/testing.html) - For store testing
## Getting Started

View File

@@ -1,7 +1,5 @@
# Component Testing Guide
> **Note**: New component tests must use `@testing-library/vue` with `@testing-library/user-event`. The examples below that use `@vue/test-utils` (`mount`, `wrapper`) are from legacy tests. An ESLint rule enforces this — importing from `@vue/test-utils` in `*.test.ts` files produces a lint error.
This guide covers patterns and examples for testing Vue components in the ComfyUI Frontend codebase.
## Table of Contents

View File

@@ -432,23 +432,6 @@ export default defineConfig([
]
}
},
{
files: ['**/*.test.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@vue/test-utils',
message:
'Use @testing-library/vue with @testing-library/user-event instead.'
}
]
}
]
}
},
// Browser tests must use comfyPageFixture, not raw @playwright/test test
{
files: ['browser_tests/tests/**/*.spec.ts'],

View File

@@ -150,6 +150,7 @@
"@vitejs/plugin-vue": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"@vue/test-utils": "catalog:",
"@webgpu/types": "catalog:",
"cross-env": "catalog:",
"eslint": "catalog:",

6
pnpm-lock.yaml generated
View File

@@ -171,6 +171,9 @@ catalogs:
'@vitest/ui':
specifier: ^4.0.16
version: 4.0.16
'@vue/test-utils':
specifier: ^2.4.6
version: 2.4.6
'@vueuse/core':
specifier: ^14.2.0
version: 14.2.0
@@ -690,6 +693,9 @@ importers:
'@vitest/ui':
specifier: 'catalog:'
version: 4.0.16(vitest@4.0.16)
'@vue/test-utils':
specifier: 'catalog:'
version: 2.4.6
'@webgpu/types':
specifier: 'catalog:'
version: 0.1.66

View File

@@ -58,6 +58,7 @@ catalog:
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
'@vitest/ui': ^4.0.16
'@vue/test-utils': ^2.4.6
'@vueuse/core': ^14.2.0
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -6,11 +6,8 @@ import RangeEditor from './RangeEditor.vue'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function renderEditor(props: {
modelValue: { min: number; max: number; midpoint?: number }
[key: string]: unknown
}) {
return render(RangeEditor, {
function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
return mount(RangeEditor, {
props,
global: { plugins: [i18n] }
})
@@ -18,19 +15,20 @@ function renderEditor(props: {
describe('RangeEditor', () => {
it('renders with min and max handles', () => {
renderEditor({ modelValue: { min: 0.2, max: 0.8 } })
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
expect(screen.getByTestId('handle-min')).toBeDefined()
expect(screen.getByTestId('handle-max')).toBeDefined()
expect(wrapper.find('svg').exists()).toBe(true)
expect(wrapper.find('[data-testid="handle-min"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="handle-max"]').exists()).toBe(true)
})
it('highlights selected range in plain mode', () => {
renderEditor({ modelValue: { min: 0.2, max: 0.8 } })
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
const highlight = screen.getByTestId('range-highlight')
expect(highlight.getAttribute('x')).toBe('0.2')
const highlight = wrapper.find('[data-testid="range-highlight"]')
expect(highlight.attributes('x')).toBe('0.2')
expect(
Number.parseFloat(highlight.getAttribute('width') ?? 'NaN')
Number.parseFloat(highlight.attributes('width') ?? 'NaN')
).toBeCloseTo(0.6, 6)
})
@@ -39,37 +37,37 @@ describe('RangeEditor', () => {
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
renderEditor({
const wrapper = mountEditor({
modelValue: { min: 0.2, max: 0.8 },
display: 'histogram',
histogram
})
const left = screen.getByTestId('range-dim-left')
const right = screen.getByTestId('range-dim-right')
expect(left.getAttribute('width')).toBe('0.2')
expect(right.getAttribute('x')).toBe('0.8')
const left = wrapper.find('[data-testid="range-dim-left"]')
const right = wrapper.find('[data-testid="range-dim-right"]')
expect(left.attributes('width')).toBe('0.2')
expect(right.attributes('x')).toBe('0.8')
})
it('hides midpoint handle by default', () => {
renderEditor({
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 }
})
expect(screen.queryByTestId('handle-midpoint')).toBeNull()
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(false)
})
it('shows midpoint handle when showMidpoint is true', () => {
renderEditor({
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 },
showMidpoint: true
})
expect(screen.getByTestId('handle-midpoint')).toBeDefined()
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(true)
})
it('renders gradient background when display is gradient', () => {
renderEditor({
const wrapper = mountEditor({
modelValue: { min: 0, max: 1 },
display: 'gradient',
gradientStops: [
@@ -78,8 +76,8 @@ describe('RangeEditor', () => {
]
})
expect(screen.getByTestId('gradient-bg')).toBeDefined()
expect(screen.getByTestId('gradient-def')).toBeDefined()
expect(wrapper.find('[data-testid="gradient-bg"]').exists()).toBe(true)
expect(wrapper.find('linearGradient').exists()).toBe(true)
})
it('renders histogram path when display is histogram with data', () => {
@@ -87,43 +85,47 @@ describe('RangeEditor', () => {
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
renderEditor({
const wrapper = mountEditor({
modelValue: { min: 0, max: 1 },
display: 'histogram',
histogram
})
expect(screen.getByTestId('histogram-path')).toBeDefined()
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(true)
})
it('renders inputs for min and max', () => {
renderEditor({ modelValue: { min: 0.2, max: 0.8 } })
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
const inputs = screen.getAllByRole('textbox')
const inputs = wrapper.findAll('input')
expect(inputs).toHaveLength(2)
})
it('renders midpoint input when showMidpoint is true', () => {
renderEditor({
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 },
showMidpoint: true
})
const inputs = screen.getAllByRole('textbox')
const inputs = wrapper.findAll('input')
expect(inputs).toHaveLength(3)
})
it('normalizes handle positions with custom value range', () => {
renderEditor({
const wrapper = mountEditor({
modelValue: { min: 64, max: 192 },
valueMin: 0,
valueMax: 255
})
const minHandle = screen.getByTestId('handle-min')
const maxHandle = screen.getByTestId('handle-max')
const minHandle = wrapper.find('[data-testid="handle-min"]')
const maxHandle = wrapper.find('[data-testid="handle-max"]')
expect(Number.parseFloat(minHandle.style.left)).toBeCloseTo(25, 0)
expect(Number.parseFloat(maxHandle.style.left)).toBeCloseTo(75, 0)
expect(
Number.parseFloat((minHandle.element as HTMLElement).style.left)
).toBeCloseTo(25, 0)
expect(
Number.parseFloat((maxHandle.element as HTMLElement).style.left)
).toBeCloseTo(75, 0)
})
})

View File

@@ -17,14 +17,7 @@
"
>
<defs v-if="display === 'gradient'">
<linearGradient
:id="gradientId"
data-testid="gradient-def"
x1="0"
y1="0"
x2="1"
y2="0"
>
<linearGradient :id="gradientId" x1="0" y1="0" x2="1" y2="0">
<stop
v-for="(stop, i) in computedStops"
:key="i"

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
@@ -17,8 +17,8 @@ const i18n = createI18n({
}
})
function renderRoleBadge(role: 'owner' | 'member') {
return render(RoleBadge, {
function mountRoleBadge(role: 'owner' | 'member') {
return mount(RoleBadge, {
props: { role },
global: { plugins: [i18n] }
})
@@ -26,12 +26,12 @@ function renderRoleBadge(role: 'owner' | 'member') {
describe('RoleBadge', () => {
it('renders the owner label', () => {
renderRoleBadge('owner')
expect(screen.getByText('Owner')).toBeInTheDocument()
const wrapper = mountRoleBadge('owner')
expect(wrapper.text()).toBe('Owner')
})
it('renders the member label', () => {
renderRoleBadge('member')
expect(screen.getByText('Member')).toBeInTheDocument()
const wrapper = mountRoleBadge('member')
expect(wrapper.text()).toBe('Member')
})
})

View File

@@ -1,19 +1,13 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import LinearWelcome from './LinearWelcome.vue'
const { hasNodes, hasOutputs, enterBuilder } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
hasNodes: ref(false),
hasOutputs: ref(false),
enterBuilder: vi.fn()
}
})
const hasNodes = ref(false)
const hasOutputs = ref(false)
const enterBuilder = vi.fn()
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: vi.fn() })
@@ -39,12 +33,12 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
function renderComponent(
function mountComponent(
opts: { hasNodes?: boolean; hasOutputs?: boolean } = {}
) {
hasNodes.value = opts.hasNodes ?? false
hasOutputs.value = opts.hasOutputs ?? false
return render(LinearWelcome, {
return mount(LinearWelcome, {
global: { plugins: [i18n] }
})
}
@@ -57,27 +51,30 @@ describe('LinearWelcome', () => {
})
it('shows empty workflow text when there are no nodes', () => {
renderComponent({ hasNodes: false })
const wrapper = mountComponent({ hasNodes: false })
expect(
screen.getByTestId('linear-welcome-empty-workflow')
).toBeInTheDocument()
wrapper.find('[data-testid="linear-welcome-empty-workflow"]').exists()
).toBe(true)
expect(
screen.queryByTestId('linear-welcome-build-app')
).not.toBeInTheDocument()
wrapper.find('[data-testid="linear-welcome-build-app"]').exists()
).toBe(false)
})
it('shows build app button when there are nodes but no outputs', () => {
renderComponent({ hasNodes: true, hasOutputs: false })
const wrapper = mountComponent({ hasNodes: true, hasOutputs: false })
expect(
screen.queryByTestId('linear-welcome-empty-workflow')
).not.toBeInTheDocument()
expect(screen.getByTestId('linear-welcome-build-app')).toBeInTheDocument()
wrapper.find('[data-testid="linear-welcome-empty-workflow"]').exists()
).toBe(false)
expect(
wrapper.find('[data-testid="linear-welcome-build-app"]').exists()
).toBe(true)
})
it('clicking build app button calls enterBuilder', async () => {
const user = userEvent.setup()
renderComponent({ hasNodes: true, hasOutputs: false })
await user.click(screen.getByTestId('linear-welcome-build-app'))
const wrapper = mountComponent({ hasNodes: true, hasOutputs: false })
await wrapper
.find('[data-testid="linear-welcome-build-app"]')
.trigger('click')
expect(enterBuilder).toHaveBeenCalled()
})
})

View File

@@ -1,7 +1,9 @@
/* eslint-disable testing-library/no-container */
/* eslint-disable testing-library/no-node-access */
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { render } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
@@ -11,6 +13,7 @@ import type {
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -95,6 +98,35 @@ describe('NodeWidgets', () => {
})
}
function mountComponent(nodeData?: VueNodeData, setupStores?: () => void) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return mount(NodeWidgets, {
props: { nodeData },
global: {
plugins: [pinia],
stubs: { InputSlot: true },
mocks: { $t: (key: string) => key }
}
})
}
const getBorderStyles = (wrapper: ReturnType<typeof mount>) =>
fromAny<{ processedWidgets: unknown[] }, unknown>(
wrapper.vm
).processedWidgets.map(
(entry) =>
(
entry as {
simplified: {
borderStyle?: string
}
}
).simplified.borderStyle
)
describe('node-type prop passing', () => {
it('passes node type to widget components', () => {
const widget = createMockWidget()
@@ -123,6 +155,19 @@ describe('NodeWidgets', () => {
expect(stub).not.toBeNull()
expect(stub!.getAttribute('data-node-type')).toBe('')
})
it.for(['CheckpointLoaderSimple', 'LoraLoader', 'VAELoader', 'KSampler'])(
'passes correct node type: %s',
(nodeType) => {
const widget = createMockWidget()
const nodeData = createMockNodeData(nodeType, [widget])
const { container } = renderComponent(nodeData)
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
expect(stub!.getAttribute('data-node-type')).toBe(nodeType)
}
)
})
it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => {
@@ -273,6 +318,54 @@ describe('NodeWidgets', () => {
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('applies promoted border styling to intermediate promoted widgets using host node identity', async () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
const wrapper = mountComponent(nodeData, () => {
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
})
await nextTick()
const borderStyles = getBorderStyles(wrapper)
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(true)
})
it('does not apply promoted border styling to outermost widgets', async () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '4')
const wrapper = mountComponent(nodeData, () => {
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
})
await nextTick()
const borderStyles = getBorderStyles(wrapper)
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(
false
)
})
it('hides widgets when merged store options mark them hidden', async () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({

View File

@@ -80,16 +80,56 @@
</template>
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import type { TooltipOptions } from 'primevue'
import { computed, onErrorCaptured, ref, toValue } from 'vue'
import type { Component } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type {
SafeWidgetData,
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
// Import widget components directly
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
getComponent,
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type {
LinkedUpstreamInfo,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
getExecutionIdFromNodeData,
getLocatorIdFromNodeData
} from '@/utils/graphTraversalUtil'
import { app } from '@/scripts/app'
import InputSlot from './InputSlot.vue'
@@ -101,7 +141,12 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const { isSelectInputsMode } = useAppMode()
const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
const promotionStore = usePromotionStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
function handleWidgetPointerEvent(event: PointerEvent) {
if (shouldHandleNodePointerEvents.value) return
@@ -115,6 +160,8 @@ function handleBringToFront() {
}
}
const { handleNodeRightClick } = useNodeEventHandlers()
// Error boundary implementation
const renderError = ref<string | null>(null)
@@ -126,11 +173,314 @@ onErrorCaptured((error) => {
return false
})
const {
canSelectInputs,
gridTemplateRows,
nodeType,
processedWidgets,
showAdvanced
} = useProcessedWidgets(() => nodeData)
const canSelectInputs = computed(
() =>
isSelectInputsMode.value &&
nodeData?.mode === LGraphEventMode.ALWAYS &&
nodeTypeValidForApp(nodeData.type) &&
!nodeData.hasErrors
)
const nodeType = computed(() => nodeData?.type || '')
const settingStore = useSettingStore()
const showAdvanced = computed(
() =>
nodeData?.showAdvanced ||
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
nodeType.value
)
const widgetValueStore = useWidgetValueStore()
function createWidgetUpdateHandler(
widgetState: WidgetState | undefined,
widget: SafeWidgetData,
nodeExecId: string,
widgetOptions: IWidgetOptions | Record<string, never>
): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
if (widgetState) widgetState.value = newValue
widget.callback?.(newValue)
const effectiveExecId = widget.sourceExecutionId ?? nodeExecId
executionErrorStore.clearWidgetRelatedErrors(
effectiveExecId,
widget.slotName ?? widget.name,
widget.name,
newValue,
{ min: widgetOptions?.min, max: widgetOptions?.max }
)
}
}
interface ProcessedWidget {
advanced: boolean
handleContextMenu: (e: PointerEvent) => void
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
id: string
name: string
renderKey: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
vueComponent: Component
slotMetadata?: WidgetSlotMetadata
}
function hasWidgetError(
widget: SafeWidgetData,
nodeExecId: string,
nodeErrors: { errors: { extra_info?: { input_name?: string } }[] } | undefined
): boolean {
const errors = widget.sourceExecutionId
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
: nodeErrors?.errors
const inputName = widget.slotName ?? widget.name
return (
!!errors?.some((e) => e.extra_info?.input_name === inputName) ||
missingModelStore.isWidgetMissingModel(
widget.sourceExecutionId ?? nodeExecId,
widget.name
)
)
}
function getWidgetIdentity(
widget: SafeWidgetData,
nodeId: string | number | undefined,
index: number
): {
dedupeIdentity?: string
renderKey: string
} {
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
const storeWidgetName = widget.storeName ?? widget.name
const slotNameForIdentity = widget.slotName ?? widget.name
const stableIdentityRoot = rawWidgetId
? `node:${String(stripGraphPrefix(rawWidgetId))}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: undefined
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
: undefined
const renderKey =
dedupeIdentity ??
`transient:${String(nodeId ?? '')}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}:${index}`
return {
dedupeIdentity,
renderKey
}
}
function isWidgetVisible(options: IWidgetOptions): boolean {
const hidden = options.hidden ?? false
const advanced = options.advanced ?? false
return !hidden && (!advanced || showAdvanced.value)
}
const processedWidgets = computed((): ProcessedWidget[] => {
if (!nodeData?.widgets) return []
// nodeData.id is the local node ID; subgraph nodes need the full execution
// path (e.g. "65:63") to match keys in lastNodeErrors.
const nodeExecId = app.isGraphReady
? getExecutionIdFromNodeData(app.rootGraph, nodeData)
: String(nodeData.id ?? '')
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
const graphId = canvasStore.canvas?.graph?.rootGraph.id
const nodeId = nodeData.id
const { widgets } = nodeData
const result: ProcessedWidget[] = []
const uniqueWidgets: Array<{
widget: SafeWidgetData
identity: ReturnType<typeof getWidgetIdentity>
mergedOptions: IWidgetOptions
widgetState: WidgetState | undefined
isVisible: boolean
}> = []
const dedupeIndexByIdentity = new Map<string, number>()
for (const [index, widget] of widgets.entries()) {
if (!shouldRenderAsVue(widget)) continue
const identity = getWidgetIdentity(widget, nodeId, index)
const storeWidgetName = widget.storeName ?? widget.name
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
: undefined
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
}
const visible = isWidgetVisible(mergedOptions)
if (!identity.dedupeIdentity) {
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
if (existingIndex === undefined) {
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingWidget = uniqueWidgets[existingIndex]
if (existingWidget && !existingWidget.isVisible && visible) {
uniqueWidgets[existingIndex] = {
widget,
identity,
mergedOptions,
widgetState,
isVisible: true
}
}
}
for (const {
widget,
mergedOptions,
widgetState,
identity: { renderKey }
} of uniqueWidgets) {
const hostNodeId = String(nodeId ?? '')
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const promotionSourceNodeId = widget.storeName
? String(bareWidgetId)
: undefined
const vueComponent =
getComponent(widget.type) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const { slotMetadata } = widget
// Get value from store (falls back to undefined if not registered)
const value = widgetState?.value as WidgetValue
// Build options from store state, with disabled override for
// slot-linked widgets or widgets with disabled state (e.g. display-only)
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle =
graphId &&
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: hostNodeId,
sourceWidgetName: widget.storeName ?? widget.name,
disambiguatingSourceNodeId: promotionSourceNodeId
})
? 'ring ring-component-node-widget-promoted'
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId
? {
nodeId: slotMetadata.originNodeId,
outputName: slotMetadata.originOutputName
}
: undefined
const nodeLocatorId = widget.nodeId
? widget.nodeId
: nodeData
? getLocatorIdFromNodeData(nodeData)
: undefined
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
value,
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widget.promotedLabel ?? widgetState?.label,
linkedUpstream,
nodeLocatorId,
options: widgetOptions,
spec: widget.spec
}
const updateHandler = createWidgetUpdateHandler(
widgetState,
widget,
nodeExecId,
widgetOptions
)
const tooltipText = getWidgetTooltip(widget)
const tooltipConfig = createTooltipConfig(tooltipText)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
handleNodeRightClick(e, nodeId)
showNodeOptions(
e,
widget.name,
widget.nodeId !== undefined
? String(stripGraphPrefix(widget.nodeId))
: undefined
)
}
result.push({
advanced: mergedOptions.advanced ?? false,
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError: hasWidgetError(widget, nodeExecId, nodeErrors),
hidden: mergedOptions.hidden ?? false,
id: String(bareWidgetId),
name: widget.name,
renderKey,
type: widget.type,
vueComponent,
simplified,
value,
updateHandler,
tooltipConfig,
slotMetadata
})
}
return result
})
const gridTemplateRows = computed((): string => {
// Use processedWidgets directly since it already has store-based hidden/advanced
return toValue(processedWidgets)
.filter((w) => !w.hidden && (!w.advanced || showAdvanced.value))
.map((w) =>
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
)
.join(' ')
})
</script>

View File

@@ -1,499 +0,0 @@
import type { TooltipOptions } from 'primevue'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import {
computeProcessedWidgets,
getWidgetIdentity,
hasWidgetError,
isWidgetVisible
} from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { usePromotionStore } from '@/stores/promotionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: {
graph: {
rootGraph: {
id: 'graph-test'
}
}
}
})
}))
const createMockWidget = (
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData => ({
nodeId: 'test_node',
name: 'test_widget',
type: 'combo',
options: undefined,
callback: undefined,
spec: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
})
describe('getWidgetIdentity', () => {
it('returns stable dedupeIdentity for widgets with storeNodeId', () => {
const widget = createMockWidget({
storeNodeId: 'subgraph:19',
storeName: 'text',
slotName: 'text',
type: 'text'
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '1', 0)
expect(dedupeIdentity).toBe('node:19:text:text:text')
expect(renderKey).toBe(dedupeIdentity)
})
it('returns transient renderKey for widgets without stable identity', () => {
const widget = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
expect(dedupeIdentity).toBeUndefined()
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
})
it('uses sourceExecutionId for identity when no nodeId', () => {
const widget = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: '65:18'
})
const { dedupeIdentity } = getWidgetIdentity(widget, '1', 0)
expect(dedupeIdentity).toBe('exec:65:18:test_widget:test_widget:combo')
})
})
describe('isWidgetVisible', () => {
it('returns true for normal widgets', () => {
expect(isWidgetVisible({}, false)).toBe(true)
})
it('returns false for hidden widgets', () => {
expect(isWidgetVisible({ hidden: true }, false)).toBe(false)
})
it('returns false for advanced widgets when showAdvanced is false', () => {
expect(isWidgetVisible({ advanced: true }, false)).toBe(false)
})
it('returns true for advanced widgets when showAdvanced is true', () => {
expect(isWidgetVisible({ advanced: true }, true)).toBe(true)
})
})
describe('hasWidgetError', () => {
let executionErrorStore: ReturnType<typeof useExecutionErrorStore>
let missingModelStore: ReturnType<typeof useMissingModelStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
executionErrorStore = useExecutionErrorStore()
missingModelStore = useMissingModelStore()
})
it('returns false when no errors', () => {
const widget = createMockWidget()
expect(
hasWidgetError(
widget,
'1',
undefined,
executionErrorStore,
missingModelStore
)
).toBe(false)
})
it('returns true when node has matching input error', () => {
const widget = createMockWidget({ name: 'seed' })
const nodeErrors = {
errors: [{ extra_info: { input_name: 'seed' } }]
}
expect(
hasWidgetError(
widget,
'1',
nodeErrors,
executionErrorStore,
missingModelStore
)
).toBe(true)
})
it('returns true via sourceExecutionId when execution store has matching error', () => {
const widget = createMockWidget({
name: 'seed',
sourceExecutionId: '65:18'
})
executionErrorStore.lastNodeErrors = {
'65:18': {
errors: [
{
type: 'required_input_missing',
message: 'seed is required',
details: '',
extra_info: { input_name: 'seed' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
expect(
hasWidgetError(
widget,
'1',
undefined,
executionErrorStore,
missingModelStore
)
).toBe(true)
})
it('returns true when widget has missing model', () => {
const widget = createMockWidget({ name: 'ckpt_name' })
vi.spyOn(missingModelStore, 'isWidgetMissingModel').mockReturnValue(true)
expect(
hasWidgetError(
widget,
'1',
undefined,
executionErrorStore,
missingModelStore
)
).toBe(true)
})
it('uses slotName for error matching when present', () => {
const widget = createMockWidget({
name: 'internal_name',
slotName: 'display_slot'
})
const nodeErrors = {
errors: [{ extra_info: { input_name: 'display_slot' } }]
}
expect(
hasWidgetError(
widget,
'1',
nodeErrors,
executionErrorStore,
missingModelStore
)
).toBe(true)
})
})
const noopUi = {
getTooltipConfig: () => ({}) as TooltipOptions,
handleNodeRightClick: () => {}
}
describe('computeProcessedWidgets borderStyle', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('applies promoted border styling to intermediate promoted widgets', () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
const result = computeProcessedWidgets({
nodeData: {
id: '3',
type: 'SubgraphNode',
widgets: [promotedWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
).toBe(true)
})
it('does not apply promoted border styling to outermost widgets', () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
const result = computeProcessedWidgets({
nodeData: {
id: '4',
type: 'SubgraphNode',
widgets: [promotedWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
).toBe(false)
})
it('applies advanced border styling to advanced widgets', () => {
const advancedWidget = createMockWidget({
name: 'text',
type: 'combo',
options: { advanced: true }
})
const result = computeProcessedWidgets({
nodeData: {
id: '1',
type: 'TestNode',
widgets: [advancedWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: true,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result[0].simplified.borderStyle).toBe(
'ring ring-component-node-widget-advanced'
)
})
it('deduplication keeps visible widget over hidden duplicate', () => {
const hiddenWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: '1',
storeNodeId: '1',
storeName: 'text',
slotName: 'text',
options: { hidden: true }
})
const visibleWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: '1',
storeNodeId: '1',
storeName: 'text',
slotName: 'text'
})
const result = computeProcessedWidgets({
nodeData: {
id: '1',
type: 'TestNode',
widgets: [hiddenWidget, visibleWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result).toHaveLength(1)
expect(result[0].hidden).toBe(false)
})
})
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
const GRAPH_ID = 'graph-test'
const NODE_ID = '1'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function processWidgets(widgets: SafeWidgetData[]) {
return computeProcessedWidgets({
nodeData: {
id: NODE_ID,
type: 'TestNode',
widgets,
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: GRAPH_ID,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
}
it('calls widget.callback with the new value when widgetState exists', () => {
const callback = vi.fn()
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID,
callback
})
useWidgetValueStore().registerWidget(GRAPH_ID, {
nodeId: NODE_ID,
name: 'seed',
type: 'combo',
value: 0,
options: {}
})
const [processed] = processWidgets([widget])
processed.updateHandler(42)
expect(callback).toHaveBeenCalledWith(42)
})
it('calls widget.callback even when widgetState is undefined (no store entry)', () => {
const callback = vi.fn()
const widget = createMockWidget({
name: 'unregistered_widget',
nodeId: NODE_ID,
callback
})
const [processed] = processWidgets([widget])
processed.updateHandler('new-value')
expect(callback).toHaveBeenCalledWith('new-value')
})
it('updates widgetState.value when store entry exists', () => {
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID
})
useWidgetValueStore().registerWidget(GRAPH_ID, {
nodeId: NODE_ID,
name: 'seed',
type: 'combo',
value: 0,
options: {}
})
const [processed] = processWidgets([widget])
processed.updateHandler(99)
const state = useWidgetValueStore().getWidget(GRAPH_ID, NODE_ID, 'seed')
expect(state?.value).toBe(99)
})
it('clears execution errors on update', () => {
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID
})
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
executionErrorStore.lastNodeErrors = {
[NODE_ID]: {
errors: [
{
type: 'required_input_missing',
message: 'seed is required',
details: '',
extra_info: { input_name: 'seed' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
const [processed] = processWidgets([widget])
expect(
hasWidgetError(
widget,
NODE_ID,
executionErrorStore.lastNodeErrors[NODE_ID],
executionErrorStore,
missingModelStore
)
).toBe(true)
processed.updateHandler('fixed-value')
expect(
hasWidgetError(
widget,
NODE_ID,
executionErrorStore.lastNodeErrors?.[NODE_ID],
executionErrorStore,
missingModelStore
)
).toBe(false)
})
})

View File

@@ -1,431 +0,0 @@
import type { TooltipOptions } from 'primevue'
import { computed } from 'vue'
import type { Component } from 'vue'
import type {
SafeWidgetData,
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
getComponent,
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type {
LinkedUpstreamInfo,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
import {
getExecutionIdFromNodeData,
getLocatorIdFromNodeData
} from '@/utils/graphTraversalUtil'
interface ProcessedWidget {
advanced: boolean
handleContextMenu: (e: PointerEvent) => void
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
id: string
name: string
renderKey: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
vueComponent: Component
slotMetadata?: WidgetSlotMetadata
}
interface WidgetUiCallbacks {
getTooltipConfig: (widget: SafeWidgetData) => TooltipOptions
handleNodeRightClick: (e: PointerEvent, nodeId: string) => void
}
interface ComputeProcessedWidgetsOptions {
nodeData: VueNodeData | undefined
graphId: string | undefined
showAdvanced: boolean
isGraphReady: boolean
rootGraph: LGraph | null
ui: WidgetUiCallbacks
}
function createWidgetUpdateHandler(
widgetState: WidgetState | undefined,
widget: SafeWidgetData,
nodeExecId: string,
widgetOptions: IWidgetOptions | Record<string, never>,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
if (widgetState) widgetState.value = newValue
widget.callback?.(newValue)
const effectiveExecId = widget.sourceExecutionId ?? nodeExecId
executionErrorStore.clearWidgetRelatedErrors(
effectiveExecId,
widget.slotName ?? widget.name,
widget.name,
newValue,
{ min: widgetOptions?.min, max: widgetOptions?.max }
)
}
}
export function hasWidgetError(
widget: SafeWidgetData,
nodeExecId: string,
nodeErrors:
| { errors: { extra_info?: { input_name?: string } }[] }
| undefined,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): boolean {
const errors = widget.sourceExecutionId
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
: nodeErrors?.errors
const inputName = widget.slotName ?? widget.name
return (
!!errors?.some((e) => e.extra_info?.input_name === inputName) ||
missingModelStore.isWidgetMissingModel(
widget.sourceExecutionId ?? nodeExecId,
widget.name
)
)
}
export function getWidgetIdentity(
widget: SafeWidgetData,
nodeId: string | number | undefined,
index: number
): {
dedupeIdentity?: string
renderKey: string
} {
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
const storeWidgetName = widget.storeName ?? widget.name
const slotNameForIdentity = widget.slotName ?? widget.name
const stableIdentityRoot = rawWidgetId
? `node:${String(stripGraphPrefix(rawWidgetId))}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: undefined
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
: undefined
const renderKey =
dedupeIdentity ??
`transient:${String(nodeId ?? '')}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}:${index}`
return {
dedupeIdentity,
renderKey
}
}
export function isWidgetVisible(
options: IWidgetOptions,
showAdvanced: boolean
): boolean {
const hidden = options.hidden ?? false
const advanced = options.advanced ?? false
return !hidden && (!advanced || showAdvanced)
}
export function computeProcessedWidgets({
nodeData,
graphId,
showAdvanced,
isGraphReady,
rootGraph,
ui
}: ComputeProcessedWidgetsOptions): ProcessedWidget[] {
if (!nodeData?.widgets) return []
const promotionStore = usePromotionStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const widgetValueStore = useWidgetValueStore()
const nodeExecId =
isGraphReady && rootGraph
? getExecutionIdFromNodeData(rootGraph, nodeData)
: String(nodeData.id ?? '')
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
const nodeId = nodeData.id
const { widgets } = nodeData
const result: ProcessedWidget[] = []
const uniqueWidgets: Array<{
widget: SafeWidgetData
identity: ReturnType<typeof getWidgetIdentity>
mergedOptions: IWidgetOptions
widgetState: WidgetState | undefined
isVisible: boolean
}> = []
const dedupeIndexByIdentity = new Map<string, number>()
for (const [index, widget] of widgets.entries()) {
if (!shouldRenderAsVue(widget)) continue
const identity = getWidgetIdentity(widget, nodeId, index)
const storeWidgetName = widget.storeName ?? widget.name
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
: undefined
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
}
const visible = isWidgetVisible(mergedOptions, showAdvanced)
if (!identity.dedupeIdentity) {
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
if (existingIndex === undefined) {
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingWidget = uniqueWidgets[existingIndex]
if (existingWidget && !existingWidget.isVisible && visible) {
uniqueWidgets[existingIndex] = {
widget,
identity,
mergedOptions,
widgetState,
isVisible: true
}
}
}
for (const {
widget,
mergedOptions,
widgetState,
identity: { renderKey }
} of uniqueWidgets) {
const hostNodeId = String(nodeId ?? '')
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const promotionSourceNodeId = widget.storeName
? String(bareWidgetId)
: undefined
const vueComponent =
getComponent(widget.type) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const { slotMetadata } = widget
const value = widgetState?.value as WidgetValue
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle =
graphId &&
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: hostNodeId,
sourceWidgetName: widget.storeName ?? widget.name,
disambiguatingSourceNodeId: promotionSourceNodeId
})
? 'ring ring-component-node-widget-promoted'
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId
? {
nodeId: slotMetadata.originNodeId,
outputName: slotMetadata.originOutputName
}
: undefined
const nodeLocatorId = widget.nodeId
? widget.nodeId
: nodeData
? getLocatorIdFromNodeData(nodeData)
: undefined
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
value,
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widget.promotedLabel ?? widgetState?.label,
linkedUpstream,
nodeLocatorId,
options: widgetOptions,
spec: widget.spec
}
const updateHandler = createWidgetUpdateHandler(
widgetState,
widget,
nodeExecId,
widgetOptions,
executionErrorStore
)
const tooltipConfig = ui.getTooltipConfig(widget)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
if (nodeId !== undefined) ui.handleNodeRightClick(e, nodeId)
showNodeOptions(
e,
widget.name,
widget.nodeId !== undefined
? String(stripGraphPrefix(widget.nodeId))
: undefined
)
}
result.push({
advanced: mergedOptions.advanced ?? false,
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError: hasWidgetError(
widget,
nodeExecId,
nodeErrors,
executionErrorStore,
missingModelStore
),
hidden: mergedOptions.hidden ?? false,
id: String(bareWidgetId),
name: widget.name,
renderKey,
type: widget.type,
vueComponent,
simplified,
value,
updateHandler,
tooltipConfig,
slotMetadata
})
}
return result
}
export function useProcessedWidgets(
nodeDataGetter: () => VueNodeData | undefined
) {
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const { isSelectInputsMode } = useAppMode()
const { handleNodeRightClick } = useNodeEventHandlers()
const nodeType = computed(() => nodeDataGetter()?.type || '')
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(nodeType)
const ui: WidgetUiCallbacks = {
getTooltipConfig: (widget) => createTooltipConfig(getWidgetTooltip(widget)),
handleNodeRightClick
}
const showAdvanced = computed(
() =>
nodeDataGetter()?.showAdvanced ||
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const canSelectInputs = computed(() => {
const nodeData = nodeDataGetter()
return (
isSelectInputsMode.value &&
nodeData?.mode === LGraphEventMode.ALWAYS &&
nodeTypeValidForApp(nodeData.type) &&
!nodeData.hasErrors
)
})
const processedWidgets = computed((): ProcessedWidget[] =>
computeProcessedWidgets({
nodeData: nodeDataGetter(),
graphId: canvasStore.canvas?.graph?.rootGraph.id,
showAdvanced: showAdvanced.value,
isGraphReady: app.isGraphReady,
rootGraph: app.isGraphReady ? app.rootGraph : null,
ui
})
)
const visibleWidgets = computed(() =>
processedWidgets.value.filter((w) =>
isWidgetVisible(
{ hidden: w.hidden, advanced: w.advanced },
showAdvanced.value
)
)
)
const gridTemplateRows = computed((): string =>
visibleWidgets.value
.map((w) =>
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
)
.join(' ')
)
return {
canSelectInputs,
gridTemplateRows,
nodeType,
processedWidgets,
showAdvanced,
visibleWidgets
}
}

View File

@@ -1,14 +1,17 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { computed, nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import { computed } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { createMockWidget } from './widgetTestUtils'
@@ -52,7 +55,7 @@ vi.mock(
})
)
const { mockMediaAssets } = vi.hoisted(() => {
const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
@@ -65,7 +68,8 @@ const { mockMediaAssets } = vi.hoisted(() => {
loadMore: vi.fn(),
hasMore: ref(false),
isLoadingMore: ref(false)
}
},
mockResolveOutputAssetItems: vi.fn()
}
})
@@ -74,187 +78,732 @@ vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
resolveOutputAssetItems: vi.fn().mockResolvedValue([])
resolveOutputAssetItems: (...args: unknown[]) =>
mockResolveOutputAssetItems(...args)
}))
const mockUpdateSelectedItems = vi.hoisted(() => vi.fn())
const mockHandleFilesUpdate = vi.hoisted(() => vi.fn())
const { mockItemsRef, mockSelectedSetRef, mockFilterSelectedRef } = vi.hoisted(
() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
mockItemsRef: ref([]) as Ref<FormDropdownItem[]>,
mockSelectedSetRef: ref(new Set()) as Ref<Set<string>>,
mockFilterSelectedRef: ref('all') as Ref<string>
}
}
)
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems',
() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { computed } = require('vue')
return {
useWidgetSelectItems: () => ({
dropdownItems: computed(() => mockItemsRef.value),
displayItems: computed(() => mockItemsRef.value),
filterSelected: mockFilterSelectedRef,
filterOptions: computed(() => [
{ name: 'All', value: 'all' },
{ name: 'Inputs', value: 'inputs' }
]),
ownershipSelected: ref('all'),
showOwnershipFilter: computed(() => false),
ownershipOptions: computed(() => []),
baseModelSelected: ref(new Set<string>()),
showBaseModelFilter: computed(() => false),
baseModelOptions: computed(() => []),
selectedSet: computed(() => mockSelectedSetRef.value)
})
}
}
)
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions',
() => ({
useWidgetSelectActions: () => ({
updateSelectedItems: mockUpdateSelectedItems,
handleFilesUpdate: mockHandleFilesUpdate
})
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
describe('WidgetSelectDropdown', () => {
beforeEach(() => {
mockMediaAssets.media.value = []
mockCheckState.mockClear()
mockAssetsData.items = []
mockItemsRef.value = []
mockSelectedSetRef.value = new Set()
mockFilterSelectedRef.value = 'all'
mockUpdateSelectedItems.mockClear()
mockHandleFilesUpdate.mockClear()
})
interface WidgetSelectDropdownInstance extends ComponentPublicInstance {
inputItems: FormDropdownItem[]
outputItems: FormDropdownItem[]
dropdownItems: FormDropdownItem[]
filterSelected: string
updateSelectedItems: (selectedSet: Set<string>) => void
}
function renderComponent(
describe('WidgetSelectDropdown custom label mapping', () => {
const createSelectDropdownWidget = (
value: string = 'img_001.png',
options: {
values?: string[]
getOptionLabel?: (value?: string | null) => string
} = {},
spec?: ComboInputSpec
) =>
createMockWidget<string | undefined>({
value,
name: 'test_image_select',
type: 'combo',
options: {
values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
...options
},
spec
})
const mountComponent = (
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined,
extraProps: Record<string, unknown> = {}
) {
return render(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind: 'image',
allowUpload: true,
uploadFolder: 'input',
...extraProps
},
global: {
plugins: [PrimeVue, createTestingPinia(), i18n]
}
})
assetKind: 'image' | 'video' | 'audio' = 'image'
): VueWrapper<WidgetSelectDropdownInstance> => {
return fromAny<VueWrapper<WidgetSelectDropdownInstance>, unknown>(
mount(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind,
allowUpload: true,
uploadFolder: 'input'
},
global: {
plugins: [PrimeVue, createTestingPinia(), i18n]
}
})
)
}
it('renders the dropdown component', () => {
mockItemsRef.value = [
{ id: 'input-0', name: 'img_001.png' },
{ id: 'input-1', name: 'photo_abc.jpg' }
describe('when custom labels are not provided', () => {
it('uses values as labels when no mapping provided', () => {
const widget = createSelectDropdownWidget('img_001.png')
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems).toHaveLength(3)
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('hash789.png')
})
})
describe('when custom labels are provided via getOptionLabel', () => {
it('displays custom labels while preserving original values', () => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (!value) return 'No file'
const mapping: Record<string, string> = {
'img_001.png': 'Vacation Photo',
'photo_abc.jpg': 'Family Portrait',
'hash789.png': 'Sunset Beach'
}
return mapping[value] || value
})
const widget = createSelectDropdownWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems).toHaveLength(3)
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Vacation Photo')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('Family Portrait')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('Sunset Beach')
expect(getOptionLabel).toHaveBeenCalledWith('img_001.png')
expect(getOptionLabel).toHaveBeenCalledWith('photo_abc.jpg')
expect(getOptionLabel).toHaveBeenCalledWith('hash789.png')
})
it('emits original values when items with custom labels are selected', async () => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (!value) return 'No file'
return `Custom: ${value}`
})
const widget = createSelectDropdownWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
// Simulate selecting an item
const selectedSet = new Set(['input-1']) // index 1 = photo_abc.jpg
wrapper.vm.updateSelectedItems(selectedSet)
// Should emit the original value, not the custom label
expect(wrapper.emitted('update:modelValue')).toBeDefined()
expect(wrapper.emitted('update:modelValue')![0]).toEqual([
'photo_abc.jpg'
])
})
it('falls back to original value when label mapping fails', () => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (value === 'photo_abc.jpg') {
throw new Error('Mapping failed')
}
return `Labeled: ${value}`
})
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const widget = createSelectDropdownWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Labeled: img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('Labeled: hash789.png')
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('falls back to original value when label mapping returns empty string', () => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (value === 'photo_abc.jpg') {
return ''
}
return `Labeled: ${value}`
})
const widget = createSelectDropdownWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Labeled: img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('Labeled: hash789.png')
})
it('falls back to original value when label mapping returns undefined', () => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (value === 'hash789.png') {
return fromAny<string, unknown>(undefined)
}
return `Labeled: ${value}`
})
const widget = createSelectDropdownWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Labeled: img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('Labeled: photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('hash789.png')
})
})
describe('output items with custom label mapping', () => {
it('applies custom label mapping to output items from queue history', () => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (!value) return 'No file'
return `Output: ${value}`
})
const widget = createSelectDropdownWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const outputItems = wrapper.vm.outputItems
expect(outputItems).toBeDefined()
expect(Array.isArray(outputItems)).toBe(true)
})
})
describe('missing value handling for template-loaded nodes', () => {
it('creates a fallback item in "all" filter when modelValue is not in available items', () => {
const widget = createSelectDropdownWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems).toHaveLength(2)
expect(
inputItems.some((item) => item.name === 'template_image.png')
).toBe(false)
// The missing value should be accessible via dropdownItems when filter is 'all' (default)
const dropdownItems = wrapper.vm.dropdownItems
expect(
dropdownItems.some((item) => item.name === 'template_image.png')
).toBe(true)
expect(dropdownItems[0].name).toBe('template_image.png')
expect(dropdownItems[0].id).toBe('missing-template_image.png')
})
it('does not include fallback item when filter is "inputs"', async () => {
const widget = createSelectDropdownWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
wrapper.vm.filterSelected = 'inputs'
await wrapper.vm.$nextTick()
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not include fallback item when filter is "outputs"', async () => {
const widget = createSelectDropdownWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
wrapper.vm.filterSelected = 'outputs'
await wrapper.vm.$nextTick()
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(wrapper.vm.outputItems.length)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not create a fallback item when modelValue exists in available items', () => {
const widget = createSelectDropdownWidget('img_001.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'img_001.png')
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not create a fallback item when modelValue is undefined', () => {
const widget = createSelectDropdownWidget(
fromAny<string, unknown>(undefined),
{
values: ['img_001.png', 'photo_abc.jpg']
}
)
const wrapper = mountComponent(widget, undefined)
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
})
})
describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
interface CloudModeInstance extends ComponentPublicInstance {
dropdownItems: FormDropdownItem[]
displayItems: FormDropdownItem[]
selectedSet: Set<string>
}
const createTestAsset = (
id: string,
name: string,
preview_url: string
): AssetItem => ({
id,
name,
preview_url,
tags: []
})
const createCloudModeWidget = (
value: string = 'model.safetensors'
): SimplifiedWidget<string | undefined> => ({
name: 'test_model_select',
type: 'combo',
value,
options: {
values: [],
nodeType: 'CheckpointLoaderSimple'
}
})
const mountCloudComponent = (
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined
): VueWrapper<CloudModeInstance> => {
return fromAny<VueWrapper<CloudModeInstance>, unknown>(
mount(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind: 'model',
isAssetMode: true,
nodeType: 'CheckpointLoaderSimple'
},
global: {
plugins: [PrimeVue, createTestingPinia(), i18n]
}
})
)
}
beforeEach(() => {
mockAssetsData.items = []
})
it('does not include missing items in cloud asset mode dropdown', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'existing_model.safetensors',
'https://example.com/preview.jpg'
)
]
mockSelectedSetRef.value = new Set(['input-0'])
const widget = createCloudModeWidget('missing_model.safetensors')
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(1)
expect(dropdownItems[0].name).toBe('existing_model.safetensors')
expect(
dropdownItems.some((item) => item.name === 'missing_model.safetensors')
).toBe(false)
})
it('shows only available cloud assets in dropdown', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'model_a.safetensors',
'https://example.com/a.jpg'
),
createTestAsset(
'asset-2',
'model_b.safetensors',
'https://example.com/b.jpg'
)
]
const widget = createCloudModeWidget('model_a.safetensors')
const wrapper = mountCloudComponent(widget, 'model_a.safetensors')
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(dropdownItems.map((item) => item.name)).toEqual([
'model_a.safetensors',
'model_b.safetensors'
])
})
it('returns empty dropdown when no cloud assets available', () => {
mockAssetsData.items = []
const widget = createCloudModeWidget('missing_model.safetensors')
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(0)
})
it('includes missing cloud asset in displayItems for input field visibility', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'existing_model.safetensors',
'https://example.com/preview.jpg'
)
]
const widget = createCloudModeWidget('missing_model.safetensors')
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
const displayItems = wrapper.vm.displayItems
expect(displayItems).toHaveLength(2)
expect(displayItems[0].name).toBe('missing_model.safetensors')
expect(displayItems[0].id).toBe('missing-missing_model.safetensors')
expect(displayItems[1].name).toBe('existing_model.safetensors')
const selectedSet = wrapper.vm.selectedSet
expect(selectedSet.has('missing-missing_model.safetensors')).toBe(true)
})
})
describe('WidgetSelectDropdown multi-output jobs', () => {
interface MultiOutputInstance extends ComponentPublicInstance {
outputItems: FormDropdownItem[]
}
function makeMultiOutputAsset(
jobId: string,
name: string,
nodeId: string,
outputCount: number
) {
return {
id: jobId,
name,
preview_url: `/api/view?filename=${name}&type=output`,
tags: ['output'],
user_metadata: {
jobId,
nodeId,
subfolder: '',
outputCount,
allOutputs: [
{
filename: name,
subfolder: '',
type: 'output',
nodeId,
mediaType: 'images'
}
]
}
}
}
function mountMultiOutput(
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined
): VueWrapper<MultiOutputInstance> {
return fromAny<VueWrapper<MultiOutputInstance>, unknown>(
mount(WidgetSelectDropdown, {
props: { widget, modelValue, assetKind: 'image' as const },
global: { plugins: [PrimeVue, createTestingPinia(), i18n] }
})
)
}
const defaultWidget = () =>
createMockWidget<string | undefined>({
value: 'output_001.png',
name: 'test_image',
type: 'combo',
options: { values: [] }
})
beforeEach(() => {
mockMediaAssets.media.value = []
mockResolveOutputAssetItems.mockReset()
})
it('shows all outputs after resolving multi-output jobs', async () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-1', 'preview.png', '5', 3)
]
mockResolveOutputAssetItems.mockResolvedValue([
{
id: 'job-1-5-output_001.png',
name: 'output_001.png',
preview_url: '/api/view?filename=output_001.png&type=output',
tags: ['output']
},
{
id: 'job-1-5-output_002.png',
name: 'output_002.png',
preview_url: '/api/view?filename=output_002.png&type=output',
tags: ['output']
},
{
id: 'job-1-5-output_003.png',
name: 'output_003.png',
preview_url: '/api/view?filename=output_003.png&type=output',
tags: ['output']
}
])
const wrapper = mountMultiOutput(defaultWidget(), 'output_001.png')
await vi.waitFor(() => {
expect(wrapper.vm.outputItems).toHaveLength(3)
})
expect(wrapper.vm.outputItems.map((i) => i.name)).toEqual([
'output_001.png [output]',
'output_002.png [output]',
'output_003.png [output]'
])
})
it('shows preview output when job has only one output', () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-2', 'single.png', '3', 1)
]
const widget = createMockWidget<string | undefined>({
value: 'single.png',
name: 'test_image',
type: 'combo',
options: { values: [] }
})
const wrapper = mountMultiOutput(widget, 'single.png')
expect(wrapper.vm.outputItems).toHaveLength(1)
expect(wrapper.vm.outputItems[0].name).toBe('single.png [output]')
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
})
it('resolves two multi-output jobs independently', async () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-A', 'previewA.png', '1', 2),
makeMultiOutputAsset('job-B', 'previewB.png', '2', 2)
]
mockResolveOutputAssetItems.mockImplementation(async (meta) => {
if (meta.jobId === 'job-A') {
return [
{ id: 'A-1', name: 'a1.png', preview_url: '', tags: ['output'] },
{ id: 'A-2', name: 'a2.png', preview_url: '', tags: ['output'] }
]
}
return [
{ id: 'B-1', name: 'b1.png', preview_url: '', tags: ['output'] },
{ id: 'B-2', name: 'b2.png', preview_url: '', tags: ['output'] }
]
})
const wrapper = mountMultiOutput(defaultWidget(), undefined)
await vi.waitFor(() => {
expect(wrapper.vm.outputItems).toHaveLength(4)
})
const names = wrapper.vm.outputItems.map((i) => i.name)
expect(names).toContain('a1.png [output]')
expect(names).toContain('a2.png [output]')
expect(names).toContain('b1.png [output]')
expect(names).toContain('b2.png [output]')
})
it('resolves outputs when allOutputs already contains all items', async () => {
mockMediaAssets.media.value = [
{
id: 'job-complete',
name: 'preview.png',
preview_url: '/api/view?filename=preview.png&type=output',
tags: ['output'],
user_metadata: {
jobId: 'job-complete',
nodeId: '1',
subfolder: '',
outputCount: 2,
allOutputs: [
{
filename: 'out1.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
{
filename: 'out2.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
]
}
}
]
mockResolveOutputAssetItems.mockResolvedValue([
{ id: 'c-1', name: 'out1.png', preview_url: '', tags: ['output'] },
{ id: 'c-2', name: 'out2.png', preview_url: '', tags: ['output'] }
])
const wrapper = mountMultiOutput(defaultWidget(), undefined)
await vi.waitFor(() => {
expect(wrapper.vm.outputItems).toHaveLength(2)
})
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
expect.objectContaining({ jobId: 'job-complete' }),
expect.any(Object)
)
const names = wrapper.vm.outputItems.map((i) => i.name)
expect(names).toEqual(['out1.png [output]', 'out2.png [output]'])
})
it('falls back to preview when resolver rejects', async () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-fail', 'preview.png', '1', 3)
]
mockResolveOutputAssetItems.mockRejectedValue(new Error('network error'))
const wrapper = mountMultiOutput(defaultWidget(), undefined)
await vi.waitFor(() => {
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to resolve multi-output job',
'job-fail',
expect.any(Error)
)
})
expect(wrapper.vm.outputItems).toHaveLength(1)
expect(wrapper.vm.outputItems[0].name).toBe('preview.png [output]')
consoleWarnSpy.mockRestore()
})
})
describe('WidgetSelectDropdown undo tracking', () => {
interface UndoTrackingInstance extends ComponentPublicInstance {
updateSelectedItems: (selectedSet: Set<string>) => void
handleFilesUpdate: (files: File[]) => Promise<void>
}
const mountForUndo = (
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined
): VueWrapper<UndoTrackingInstance> => {
return fromAny<VueWrapper<UndoTrackingInstance>, unknown>(
mount(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind: 'image',
allowUpload: true,
uploadFolder: 'input'
},
global: {
plugins: [PrimeVue, createTestingPinia(), i18n]
}
})
)
}
beforeEach(() => {
mockCheckState.mockClear()
})
it('calls checkState after dropdown selection changes modelValue', () => {
const widget = createMockWidget<string | undefined>({
value: 'img_001.png',
name: 'test_image',
type: 'combo',
options: {
values: ['img_001.png', 'photo_abc.jpg']
}
options: { values: ['img_001.png', 'photo_abc.jpg'] }
})
renderComponent(widget, 'img_001.png')
expect(screen.getByText('img_001.png')).toBeDefined()
const wrapper = mountForUndo(widget, 'img_001.png')
wrapper.vm.updateSelectedItems(new Set(['input-1']))
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['photo_abc.jpg'])
expect(mockCheckState).toHaveBeenCalledOnce()
})
it('renders in cloud asset mode', () => {
mockAssetsData.items = [
{
id: 'asset-1',
name: 'model_a.safetensors',
preview_url: 'https://example.com/a.jpg',
tags: []
}
]
mockItemsRef.value = [{ id: 'asset-1', name: 'model_a.safetensors' }]
mockSelectedSetRef.value = new Set(['asset-1'])
it('calls checkState after file upload completes', async () => {
const { api } = await import('@/scripts/api')
vi.mocked(api.fetchApi).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
} as Response)
const widget = createMockWidget<string | undefined>({
value: 'model_a.safetensors',
name: 'test_model',
value: 'img_001.png',
name: 'test_image',
type: 'combo',
options: {
values: [],
nodeType: 'CheckpointLoaderSimple'
}
options: { values: ['img_001.png'] }
})
renderComponent(widget, 'model_a.safetensors', {
assetKind: 'model',
isAssetMode: true,
nodeType: 'CheckpointLoaderSimple'
})
expect(screen.getByText('model_a.safetensors')).toBeDefined()
})
const wrapper = mountForUndo(widget, 'img_001.png')
describe('composable wiring', () => {
const items: FormDropdownItem[] = [
{ id: 'input-0', name: 'cat.png', label: 'cat.png' },
{ id: 'input-1', name: 'dog.png', label: 'dog.png' }
]
const file = new File(['test'], 'uploaded.png', { type: 'image/png' })
await wrapper.vm.handleFilesUpdate([file])
function renderDefault() {
mockItemsRef.value = items
const widget = createMockWidget<string | undefined>({
value: 'cat.png',
name: 'test_image',
type: 'combo',
options: { values: ['cat.png', 'dog.png'] }
})
return renderComponent(widget, 'cat.png')
}
it('displays the item whose id is in selectedSet', async () => {
mockSelectedSetRef.value = new Set(['input-1'])
renderDefault()
expect(screen.getByText('dog.png')).toBeDefined()
expect(screen.queryByText('cat.png')).toBeNull()
})
it('shows placeholder when selectedSet is empty', () => {
mockSelectedSetRef.value = new Set()
renderDefault()
expect(screen.queryByText('cat.png')).toBeNull()
expect(screen.queryByText('dog.png')).toBeNull()
})
it('updates displayed selection when selectedSet changes', async () => {
mockSelectedSetRef.value = new Set(['input-0'])
renderDefault()
expect(screen.getByText('cat.png')).toBeDefined()
mockSelectedSetRef.value = new Set(['input-1'])
await nextTick()
expect(screen.getByText('dog.png')).toBeDefined()
expect(screen.queryByText('cat.png')).toBeNull()
})
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['uploaded.png'])
expect(mockCheckState).toHaveBeenCalledOnce()
})
})

View File

@@ -1,20 +1,45 @@
<script setup lang="ts">
import { computed, provide, ref, toRef } from 'vue'
import { capitalize } from 'es-toolkit'
import { computed, provide, ref, shallowRef, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import {
filterItemByBaseModels,
filterItemByOwnership
} from '@/platform/assets/utils/assetFilterUtils'
import {
getAssetBaseModels,
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
import type {
FilterOption,
OwnershipOption
} from '@/platform/assets/types/filterTypes'
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type {
FormDropdownItem,
LayoutMode
} from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
import { useWidgetSelectActions } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions'
import { useWidgetSelectItems } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
@@ -46,6 +71,7 @@ const modelValue = defineModel<string | undefined>({
})
const { t } = useI18n()
const toastStore = useToastStore()
const outputMediaAssets = useMediaAssets('output')
@@ -66,34 +92,261 @@ const getAssetData = () => {
}
const assetData = getAssetData()
const {
dropdownItems,
displayItems,
filterSelected,
filterOptions,
ownershipSelected,
showOwnershipFilter,
ownershipOptions,
baseModelSelected,
showBaseModelFilter,
baseModelOptions,
selectedSet
} = useWidgetSelectItems({
values: () => props.widget.options?.values as unknown[] | undefined,
getOptionLabel: () => props.widget.options?.getOptionLabel,
modelValue,
assetKind: () => props.assetKind,
outputMediaAssets,
assetData,
isAssetMode: () => props.isAssetMode
const filterSelected = ref('all')
const filterOptions = computed<FilterOption[]>(() => {
if (props.isAssetMode) {
const categoryName = assetData?.category.value ?? 'All'
return [{ name: capitalize(categoryName), value: 'all' }]
}
return [
{ name: 'All', value: 'all' },
{ name: 'Inputs', value: 'inputs' },
{ name: 'Outputs', value: 'outputs' }
]
})
const { updateSelectedItems, handleFilesUpdate } = useWidgetSelectActions({
modelValue,
dropdownItems,
widget: () => props.widget,
uploadFolder: () => props.uploadFolder,
uploadSubfolder: () => props.uploadSubfolder
const ownershipSelected = ref<OwnershipOption>('all')
const showOwnershipFilter = computed(() => props.isAssetMode)
const { ownershipOptions, availableBaseModels } = useAssetFilterOptions(
() => assetData?.assets.value ?? []
)
const baseModelSelected = ref<Set<string>>(new Set())
const showBaseModelFilter = computed(() => props.isAssetMode)
const baseModelOptions = computed<FilterOption[]>(() => {
if (!props.isAssetMode || !assetData) return []
return availableBaseModels.value
})
const selectedSet = ref<Set<string>>(new Set())
/**
* Transforms a value using getOptionLabel if available.
* Falls back to the original value if getOptionLabel is not provided,
* returns undefined/null, or throws an error.
*/
function getDisplayLabel(value: string): string {
const getOptionLabel = props.widget.options?.getOptionLabel
if (!getOptionLabel) return value
try {
return getOptionLabel(value) || value
} catch (e) {
console.error('Failed to map value:', e)
return value
}
}
const inputItems = computed<FormDropdownItem[]>(() => {
const values = props.widget.options?.values || []
if (!Array.isArray(values)) {
return []
}
return values.map((value, index) => ({
id: `input-${index}`,
preview_url: getMediaUrl(String(value), 'input'),
name: String(value),
label: getDisplayLabel(String(value))
}))
})
function assetKindToMediaType(kind: AssetKind): string {
return kind === 'mesh' ? '3D' : kind
}
/**
* Per-job cache of resolved outputs for multi-output jobs.
* Keyed by jobId, populated lazily via resolveOutputAssetItems which
* fetches full outputs through getJobDetail (itself LRU-cached).
*/
const resolvedByJobId = shallowRef(new Map<string, AssetItem[]>())
const pendingJobIds = new Set<string>()
watch(
() => outputMediaAssets.media.value,
(assets, _, onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true
})
pendingJobIds.clear()
for (const asset of assets) {
const meta = getOutputAssetMetadata(asset.user_metadata)
if (!meta) continue
const outputCount = meta.outputCount ?? meta.allOutputs?.length ?? 0
if (
outputCount <= 1 ||
resolvedByJobId.value.has(meta.jobId) ||
pendingJobIds.has(meta.jobId)
)
continue
pendingJobIds.add(meta.jobId)
void resolveOutputAssetItems(meta, { createdAt: asset.created_at })
.then((resolved) => {
if (cancelled || !resolved.length) return
const next = new Map(resolvedByJobId.value)
next.set(meta.jobId, resolved)
resolvedByJobId.value = next
})
.catch((error) => {
console.warn('Failed to resolve multi-output job', meta.jobId, error)
})
.finally(() => {
pendingJobIds.delete(meta.jobId)
})
}
},
{ immediate: true }
)
const outputItems = computed<FormDropdownItem[]>(() => {
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
return []
const targetMediaType = assetKindToMediaType(props.assetKind!)
const seen = new Set<string>()
const items: FormDropdownItem[] = []
const assets = outputMediaAssets.media.value.flatMap((asset) => {
const meta = getOutputAssetMetadata(asset.user_metadata)
const resolved = meta ? resolvedByJobId.value.get(meta.jobId) : undefined
return resolved ?? [asset]
})
for (const asset of assets) {
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
if (seen.has(asset.id)) continue
seen.add(asset.id)
const annotatedPath = `${asset.name} [output]`
items.push({
id: `output-${annotatedPath}`,
preview_url: asset.preview_url || getMediaUrl(asset.name, 'output'),
name: annotatedPath,
label: getDisplayLabel(annotatedPath)
})
}
return items
})
/**
* Creates a fallback item for the current modelValue when it doesn't exist
* in the available items list. This handles cases like template-loaded nodes
* where the saved value may not exist in the current server environment.
* Works for both local mode (inputItems/outputItems) and cloud mode (assetData).
*/
const missingValueItem = computed<FormDropdownItem | undefined>(() => {
const currentValue = modelValue.value
if (!currentValue) return undefined
// Check in cloud mode assets
if (props.isAssetMode && assetData) {
const existsInAssets = assetData.assets.value.some(
(asset) => getAssetFilename(asset) === currentValue
)
if (existsInAssets) return undefined
return {
id: `missing-${currentValue}`,
preview_url: '',
name: currentValue,
label: getDisplayLabel(currentValue)
}
}
// Check in local mode inputs/outputs
const existsInInputs = inputItems.value.some(
(item) => item.name === currentValue
)
const existsInOutputs = outputItems.value.some(
(item) => item.name === currentValue
)
if (existsInInputs || existsInOutputs) return undefined
const isOutput = currentValue.endsWith(' [output]')
const strippedValue = isOutput
? currentValue.replace(' [output]', '')
: currentValue
return {
id: `missing-${currentValue}`,
preview_url: getMediaUrl(strippedValue, isOutput ? 'output' : 'input'),
name: currentValue,
label: getDisplayLabel(currentValue)
}
})
/**
* Transforms AssetItem[] to FormDropdownItem[] for cloud mode.
* Uses getAssetFilename for display name, asset.name for label.
*/
const assetItems = computed<FormDropdownItem[]>(() => {
if (!props.isAssetMode || !assetData) return []
return assetData.assets.value.map((asset) => ({
id: asset.id,
name: getAssetFilename(asset),
label: getAssetDisplayName(asset),
preview_url: asset.preview_url,
is_immutable: asset.is_immutable,
base_models: getAssetBaseModels(asset)
}))
})
const ownershipFilteredAssetItems = computed<FormDropdownItem[]>(() =>
filterItemByOwnership(assetItems.value, ownershipSelected.value)
)
const baseModelFilteredAssetItems = computed<FormDropdownItem[]>(() =>
filterItemByBaseModels(
ownershipFilteredAssetItems.value,
baseModelSelected.value
)
)
const allItems = computed<FormDropdownItem[]>(() => {
if (props.isAssetMode && assetData) {
// Cloud assets not in user's library shouldn't appear as search results (COM-14333).
// Unlike local mode, cloud users can't access files they don't own.
return baseModelFilteredAssetItems.value
}
return [
...(missingValueItem.value ? [missingValueItem.value] : []),
...inputItems.value,
...outputItems.value
]
})
const dropdownItems = computed<FormDropdownItem[]>(() => {
if (props.isAssetMode) {
return allItems.value
}
switch (filterSelected.value) {
case 'inputs':
return inputItems.value
case 'outputs':
return outputItems.value
case 'all':
default:
return allItems.value
}
})
/**
* Items used for display in the input field. In cloud mode, includes
* missing items so users can see their selected value even if not in library.
*/
const displayItems = computed<FormDropdownItem[]>(() => {
if (props.isAssetMode && assetData && missingValueItem.value) {
return [missingValueItem.value, ...baseModelFilteredAssetItems.value]
}
return dropdownItems.value
})
const mediaPlaceholder = computed(() => {
@@ -139,12 +392,141 @@ const acceptTypes = computed(() => {
case 'mesh':
return SUPPORTED_EXTENSIONS_ACCEPT
default:
return undefined
return undefined // model or unknown
}
})
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
watch(
[modelValue, displayItems],
([currentValue]) => {
if (currentValue === undefined) {
selectedSet.value.clear()
return
}
const item = displayItems.value.find((item) => item.name === currentValue)
if (!item) {
selectedSet.value.clear()
return
}
selectedSet.value.clear()
selectedSet.value.add(item.id)
},
{ immediate: true }
)
function updateSelectedItems(selectedItems: Set<string>) {
let id: string | undefined = undefined
if (selectedItems.size > 0) {
id = selectedItems.values().next().value!
}
if (id == null) {
modelValue.value = undefined
return
}
const name = dropdownItems.value.find((item) => item.id === id)?.name
if (!name) {
modelValue.value = undefined
return
}
modelValue.value = name
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
}
const uploadFile = async (
file: File,
isPasted: boolean = false,
formFields: Partial<{ type: ResultItemType }> = {}
) => {
const body = new FormData()
body.append('image', file)
if (isPasted) body.append('subfolder', 'pasted')
else if (props.uploadSubfolder)
body.append('subfolder', props.uploadSubfolder)
if (formFields.type) body.append('type', formFields.type)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
toastStore.addAlert(resp.status + ' - ' + resp.statusText)
return null
}
const data = await resp.json()
// Update AssetsStore when uploading to input folder
if (formFields.type === 'input' || (!formFields.type && !isPasted)) {
const assetsStore = useAssetsStore()
await assetsStore.updateInputs()
}
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
const uploadFiles = async (files: File[]): Promise<string[]> => {
const folder = props.uploadFolder ?? 'input'
const uploadPromises = files.map((file) =>
uploadFile(file, false, { type: folder })
)
const results = await Promise.all(uploadPromises)
return results.filter((path): path is string => path !== null)
}
async function handleFilesUpdate(files: File[]) {
if (!files || files.length === 0) return
try {
// 1. Upload files to server
const uploadedPaths = await uploadFiles(files)
if (uploadedPaths.length === 0) {
toastStore.addAlert('File upload failed')
return
}
// 2. Update widget options to include new files
// This simulates what addToComboValues does but for SimplifiedWidget
const values = props.widget.options?.values
if (Array.isArray(values)) {
uploadedPaths.forEach((path) => {
if (!values.includes(path)) {
values.push(path)
}
})
}
// 3. Update widget value to the first uploaded file
modelValue.value = uploadedPaths[0]
// 4. Trigger callback to notify underlying LiteGraph widget
if (props.widget.callback) {
props.widget.callback(uploadedPaths[0])
}
// 5. Snapshot undo state so the image change gets its own undo entry
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
} catch (error) {
console.error('Upload error:', error)
toastStore.addAlert(`Upload failed: ${error}`)
}
}
function getMediaUrl(
filename: string,
type: 'input' | 'output' = 'input'
): string {
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
return ''
const params = new URLSearchParams({ filename, type })
appendCloudResParam(params, filename)
return `/api/view?${params}`
}
function handleIsOpenUpdate(isOpen: boolean) {
if (isOpen && !outputMediaAssets.loading.value) {
void outputMediaAssets.refresh()
@@ -155,11 +537,11 @@ function handleIsOpenUpdate(isOpen: boolean) {
<template>
<WidgetLayoutField :widget>
<FormDropdown
v-model:selected="selectedSet"
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:selected="selectedSet"
:items="dropdownItems"
:display-items="displayItems"
:placeholder="mediaPlaceholder"

View File

@@ -1,229 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import { useWidgetSelectActions } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const mockCheckState = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const actual = await vi.importActual(
'@/platform/workflow/management/stores/workflowStore'
)
return {
...actual,
useWorkflowStore: () => ({
activeWorkflow: {
changeTracker: {
checkState: mockCheckState
}
}
})
}
})
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
apiURL: vi.fn((url: string) => url),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
function createItems(...names: string[]): FormDropdownItem[] {
return names.map((name, i) => ({
id: `input-${i}`,
name,
label: name,
preview_url: ''
}))
}
describe('useWidgetSelectActions', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockCheckState.mockClear()
})
describe('updateSelectedItems', () => {
it('sets modelValue to the selected item name', () => {
const modelValue = ref<string | undefined>('img_001.png')
const items = createItems('img_001.png', 'photo_abc.jpg')
const { updateSelectedItems } = useWidgetSelectActions({
modelValue,
dropdownItems: computed(() => items),
widget: () =>
fromPartial<SimplifiedWidget<string | undefined>>({
name: 'test',
type: 'combo',
value: 'img_001.png'
}),
uploadFolder: () => 'input',
uploadSubfolder: () => undefined
})
updateSelectedItems(new Set(['input-1']))
expect(modelValue.value).toBe('photo_abc.jpg')
expect(mockCheckState).toHaveBeenCalledOnce()
})
it('clears modelValue when empty set', () => {
const modelValue = ref<string | undefined>('img_001.png')
const items = createItems('img_001.png')
const { updateSelectedItems } = useWidgetSelectActions({
modelValue,
dropdownItems: computed(() => items),
widget: () =>
fromPartial<SimplifiedWidget<string | undefined>>({
name: 'test',
type: 'combo',
value: 'img_001.png'
}),
uploadFolder: () => 'input',
uploadSubfolder: () => undefined
})
updateSelectedItems(new Set())
expect(modelValue.value).toBeUndefined()
expect(mockCheckState).toHaveBeenCalledOnce()
})
})
describe('handleFilesUpdate', () => {
it('uploads file and updates modelValue', async () => {
const { api } = await import('@/scripts/api')
vi.mocked(api.fetchApi).mockResolvedValue(
fromPartial<Response>({
status: 200,
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
})
)
const modelValue = ref<string | undefined>('img_001.png')
const items = createItems('img_001.png')
const widgetValues = ['img_001.png']
const { handleFilesUpdate } = useWidgetSelectActions({
modelValue,
dropdownItems: computed(() => items),
widget: () =>
fromPartial<SimplifiedWidget<string | undefined>>({
name: 'test',
type: 'combo',
value: 'img_001.png',
options: { values: widgetValues }
}),
uploadFolder: () => 'input',
uploadSubfolder: () => undefined
})
const file = new File(['test'], 'uploaded.png', {
type: 'image/png'
})
await handleFilesUpdate([file])
expect(modelValue.value).toBe('uploaded.png')
expect(mockCheckState).toHaveBeenCalledOnce()
})
it('adds uploaded path to widget values array', async () => {
const { api } = await import('@/scripts/api')
vi.mocked(api.fetchApi).mockResolvedValue(
fromPartial<Response>({
status: 200,
json: () => Promise.resolve({ name: 'new.png', subfolder: '' })
})
)
const modelValue = ref<string | undefined>()
const widgetValues = ['existing.png']
const { handleFilesUpdate } = useWidgetSelectActions({
modelValue,
dropdownItems: computed(() => []),
widget: () =>
fromPartial<SimplifiedWidget<string | undefined>>({
name: 'test',
type: 'combo',
options: { values: widgetValues }
}),
uploadFolder: () => 'input',
uploadSubfolder: () => undefined
})
await handleFilesUpdate([new File(['test'], 'new.png')])
expect(widgetValues).toContain('new.png')
expect(widgetValues).toHaveLength(2)
})
it('calls widget callback after upload', async () => {
const { api } = await import('@/scripts/api')
vi.mocked(api.fetchApi).mockResolvedValue(
fromPartial<Response>({
status: 200,
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
})
)
const mockCallback = vi.fn()
const modelValue = ref<string | undefined>()
const { handleFilesUpdate } = useWidgetSelectActions({
modelValue,
dropdownItems: computed(() => []),
widget: () =>
fromPartial<SimplifiedWidget<string | undefined>>({
name: 'test',
type: 'combo',
callback: mockCallback,
options: { values: [] }
}),
uploadFolder: () => 'input',
uploadSubfolder: () => undefined
})
await handleFilesUpdate([new File(['test'], 'uploaded.png')])
expect(mockCallback).toHaveBeenCalledWith('uploaded.png')
})
it('shows alert toast on upload failure', async () => {
const { api } = await import('@/scripts/api')
vi.mocked(api.fetchApi).mockResolvedValue(
fromPartial<Response>({
status: 500,
statusText: 'Internal Server Error'
})
)
const modelValue = ref<string | undefined>('original.png')
const { handleFilesUpdate } = useWidgetSelectActions({
modelValue,
dropdownItems: computed(() => []),
widget: () =>
fromPartial<SimplifiedWidget<string | undefined>>({
name: 'test',
type: 'combo',
options: { values: [] }
}),
uploadFolder: () => 'input',
uploadSubfolder: () => undefined
})
await handleFilesUpdate([new File(['test'], 'fail.png')])
expect(modelValue.value).toBe('original.png')
const toastStore = useToastStore()
expect(toastStore.addAlert).toHaveBeenCalledWith(
'500 - Internal Server Error'
)
})
})
})

View File

@@ -1,120 +0,0 @@
import { toValue } from 'vue'
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
interface UseWidgetSelectActionsOptions {
modelValue: Ref<string | undefined>
dropdownItems: ComputedRef<FormDropdownItem[]>
widget: MaybeRefOrGetter<SimplifiedWidget<string | undefined>>
uploadFolder: MaybeRefOrGetter<ResultItemType | undefined>
uploadSubfolder: MaybeRefOrGetter<string | undefined>
}
export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
const { modelValue, dropdownItems } = options
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
function checkWorkflowState() {
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
}
function updateSelectedItems(selectedItems: Set<string>) {
const id =
selectedItems.size > 0 ? selectedItems.values().next().value : undefined
const name =
id == null
? undefined
: dropdownItems.value.find((item) => item.id === id)?.name
modelValue.value = name
checkWorkflowState()
}
async function uploadFile(
file: File,
isPasted: boolean = false,
formFields: Partial<{ type: ResultItemType }> = {}
) {
const body = new FormData()
body.append('image', file)
if (isPasted) body.append('subfolder', 'pasted')
else {
const subfolder = toValue(options.uploadSubfolder)
if (subfolder) body.append('subfolder', subfolder)
}
if (formFields.type) body.append('type', formFields.type)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
toastStore.addAlert(resp.status + ' - ' + resp.statusText)
return null
}
const data = await resp.json()
if (formFields.type === 'input' || (!formFields.type && !isPasted)) {
const assetsStore = useAssetsStore()
await assetsStore.updateInputs()
}
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
async function uploadFiles(files: File[]): Promise<string[]> {
const folder = toValue(options.uploadFolder) ?? 'input'
const uploadPromises = files.map((file) =>
uploadFile(file, false, { type: folder })
)
const results = await Promise.all(uploadPromises)
return results.filter((path): path is string => path !== null)
}
const handleFilesUpdate = wrapWithErrorHandlingAsync(
async (files: File[]) => {
if (!files || files.length === 0) return
const uploadedPaths = await uploadFiles(files)
if (uploadedPaths.length === 0) {
toastStore.addAlert('File upload failed')
return
}
const widget = toValue(options.widget)
const values = widget.options?.values
if (Array.isArray(values)) {
uploadedPaths.forEach((path) => {
if (!values.includes(path)) {
values.push(path)
}
})
}
modelValue.value = uploadedPaths[0]
if (widget.callback) {
widget.callback(uploadedPaths[0])
}
checkWorkflowState()
}
)
return {
updateSelectedItems,
handleFilesUpdate
}
}

View File

@@ -1,668 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { computed, nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useWidgetSelectItems } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems'
const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] }))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData',
() => ({
useAssetWidgetData: () => ({
category: computed(() => 'checkpoints'),
assets: computed(() => mockAssetsData.items),
isLoading: computed(() => false),
error: computed(() => null)
})
})
)
const mockResolveOutputAssetItems = vi.fn()
function createMockMediaAssets() {
return {
media: ref<AssetItem[]>([]),
loading: ref(false),
error: ref(null),
fetchMediaList: vi.fn().mockResolvedValue([]),
refresh: vi.fn().mockResolvedValue([]),
loadMore: vi.fn(),
hasMore: ref(false),
isLoadingMore: ref(false)
}
}
let mockMediaAssets = createMockMediaAssets()
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
useMediaAssets: () => mockMediaAssets
}))
vi.mock('@/platform/assets/composables/useAssetFilterOptions', () => ({
useAssetFilterOptions: () => ({
ownershipOptions: computed(() => []),
availableBaseModels: computed(() => []),
availableFileFormats: computed(() => [])
})
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
resolveOutputAssetItems: (...args: unknown[]) =>
mockResolveOutputAssetItems(...args)
}))
function createDefaultOptions(
overrides: Partial<Parameters<typeof useWidgetSelectItems>[0]> = {}
) {
return {
values: () => ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
getOptionLabel: () =>
undefined as ((value?: string | null) => string) | undefined,
modelValue: ref<string | undefined>('img_001.png'),
assetKind: () => 'image' as const,
outputMediaAssets: mockMediaAssets,
assetData: null,
isAssetMode: () => false,
...overrides
}
}
describe('display label behavior', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('uses values as labels when no label function provided', () => {
const { dropdownItems } = useWidgetSelectItems(createDefaultOptions())
expect(dropdownItems.value[0]).toMatchObject({
name: 'img_001.png',
label: 'img_001.png'
})
})
it('applies custom label function', () => {
const getOptionLabel = (v?: string | null) => `Custom: ${v}`
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({ getOptionLabel: () => getOptionLabel })
)
expect(dropdownItems.value[0].label).toBe('Custom: img_001.png')
})
it('falls back to value on label function error', () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
const getOptionLabel = (v?: string | null) => {
if (v === 'photo_abc.jpg') throw new Error('fail')
return `Labeled: ${v}`
}
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({ getOptionLabel: () => getOptionLabel })
)
expect(dropdownItems.value[0].label).toBe('Labeled: img_001.png')
expect(dropdownItems.value[1].label).toBe('photo_abc.jpg')
expect(dropdownItems.value[2].label).toBe('Labeled: hash789.png')
expect(consoleWarnSpy).toHaveBeenCalled()
consoleWarnSpy.mockRestore()
})
it('falls back to value when label function returns empty string', () => {
const getOptionLabel = (v?: string | null) => {
if (v === 'photo_abc.jpg') return ''
return `Labeled: ${v}`
}
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({ getOptionLabel: () => getOptionLabel })
)
expect(dropdownItems.value[1].label).toBe('photo_abc.jpg')
})
it('falls back to value when label function returns undefined', () => {
const getOptionLabel = (v?: string | null) => {
if (v === 'hash789.png') return undefined as unknown as string
return `Labeled: ${v}`
}
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({ getOptionLabel: () => getOptionLabel })
)
expect(dropdownItems.value[2].label).toBe('hash789.png')
})
})
describe('useWidgetSelectItems', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockMediaAssets = createMockMediaAssets()
mockResolveOutputAssetItems.mockReset()
mockAssetsData.items = []
})
describe('dropdownItems', () => {
it('maps values to items with names as labels', () => {
const { dropdownItems } = useWidgetSelectItems(createDefaultOptions())
expect(dropdownItems.value).toHaveLength(3)
expect(dropdownItems.value[0]).toMatchObject({
name: 'img_001.png',
label: 'img_001.png'
})
})
it('returns empty when values is undefined and no modelValue', () => {
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => undefined,
modelValue: ref(undefined)
})
)
expect(dropdownItems.value).toHaveLength(0)
})
})
describe('missing value handling', () => {
it('creates fallback item when modelValue not in inputs', () => {
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => ['img_001.png', 'photo_abc.jpg'],
modelValue: ref('template_image.png')
})
)
expect(
dropdownItems.value.some((item) => item.name === 'template_image.png')
).toBe(true)
expect(dropdownItems.value[0].id).toBe('missing-template_image.png')
})
it('does not include fallback when filter is inputs', async () => {
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => ['img_001.png', 'photo_abc.jpg'],
modelValue: ref('template_image.png')
})
)
filterSelected.value = 'inputs'
await nextTick()
expect(dropdownItems.value).toHaveLength(2)
expect(
dropdownItems.value.every(
(item) => !String(item.id).startsWith('missing-')
)
).toBe(true)
})
it('does not include fallback when filter is outputs', async () => {
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => ['img_001.png', 'photo_abc.jpg'],
modelValue: ref('template_image.png')
})
)
filterSelected.value = 'outputs'
await nextTick()
expect(
dropdownItems.value.every(
(item) => !String(item.id).startsWith('missing-')
)
).toBe(true)
})
it('no fallback when modelValue exists in inputs', () => {
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => ['img_001.png', 'photo_abc.jpg'],
modelValue: ref('img_001.png')
})
)
expect(dropdownItems.value).toHaveLength(2)
expect(
dropdownItems.value.every(
(item) => !String(item.id).startsWith('missing-')
)
).toBe(true)
})
it('no fallback when modelValue is undefined', () => {
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => ['img_001.png', 'photo_abc.jpg'],
modelValue: ref(undefined)
})
)
expect(dropdownItems.value).toHaveLength(2)
expect(
dropdownItems.value.every(
(item) => !String(item.id).startsWith('missing-')
)
).toBe(true)
})
})
describe('cloud asset mode', () => {
const createTestAsset = (
id: string,
name: string,
preview_url: string
): AssetItem => ({
id,
name,
preview_url,
tags: []
})
it('excludes missing items from cloud dropdown', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'existing_model.safetensors',
'https://example.com/preview.jpg'
)
]
const assetData = {
category: computed(() => 'checkpoints'),
assets: computed(() => mockAssetsData.items),
isLoading: computed(() => false),
error: computed(() => null)
}
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref('missing_model.safetensors'),
assetKind: () => 'model',
isAssetMode: () => true,
assetData
})
)
expect(dropdownItems.value).toHaveLength(1)
expect(dropdownItems.value[0].name).toBe('existing_model.safetensors')
})
it('shows only available cloud assets', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'model_a.safetensors',
'https://example.com/a.jpg'
),
createTestAsset(
'asset-2',
'model_b.safetensors',
'https://example.com/b.jpg'
)
]
const assetData = {
category: computed(() => 'checkpoints'),
assets: computed(() => mockAssetsData.items),
isLoading: computed(() => false),
error: computed(() => null)
}
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref('model_a.safetensors'),
assetKind: () => 'model',
isAssetMode: () => true,
assetData
})
)
expect(dropdownItems.value).toHaveLength(2)
expect(dropdownItems.value.map((i) => i.name)).toEqual([
'model_a.safetensors',
'model_b.safetensors'
])
})
it('returns empty dropdown when no cloud assets', () => {
const assetData = {
category: computed(() => 'checkpoints'),
assets: computed(() => [] as AssetItem[]),
isLoading: computed(() => false),
error: computed(() => null)
}
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref('missing.safetensors'),
assetKind: () => 'model',
isAssetMode: () => true,
assetData
})
)
expect(dropdownItems.value).toHaveLength(0)
})
it('includes missing cloud asset in displayItems', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'existing_model.safetensors',
'https://example.com/preview.jpg'
)
]
const assetData = {
category: computed(() => 'checkpoints'),
assets: computed(() => mockAssetsData.items),
isLoading: computed(() => false),
error: computed(() => null)
}
const { displayItems, selectedSet } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref('missing_model.safetensors'),
assetKind: () => 'model',
isAssetMode: () => true,
assetData
})
)
expect(displayItems.value).toHaveLength(2)
expect(displayItems.value[0].name).toBe('missing_model.safetensors')
expect(displayItems.value[0].id).toBe('missing-missing_model.safetensors')
expect(selectedSet.value.has('missing-missing_model.safetensors')).toBe(
true
)
})
})
describe('multi-output jobs', () => {
function makeMultiOutputAsset(
jobId: string,
name: string,
nodeId: string,
outputCount: number
) {
return {
id: jobId,
name,
preview_url: `/api/view?filename=${name}&type=output`,
tags: ['output'],
user_metadata: {
jobId,
nodeId,
subfolder: '',
outputCount,
allOutputs: [
{
filename: name,
subfolder: '',
type: 'output',
nodeId,
mediaType: 'images'
}
]
}
}
}
it('shows all outputs after resolving multi-output jobs', async () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-1', 'preview.png', '5', 3)
]
mockResolveOutputAssetItems.mockResolvedValue([
{
id: 'job-1-5-output_001.png',
name: 'output_001.png',
preview_url: '/api/view?filename=output_001.png&type=output',
tags: ['output']
},
{
id: 'job-1-5-output_002.png',
name: 'output_002.png',
preview_url: '/api/view?filename=output_002.png&type=output',
tags: ['output']
},
{
id: 'job-1-5-output_003.png',
name: 'output_003.png',
preview_url: '/api/view?filename=output_003.png&type=output',
tags: ['output']
}
])
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref('output_001.png')
})
)
filterSelected.value = 'outputs'
await vi.waitFor(() => {
expect(dropdownItems.value).toHaveLength(3)
})
expect(dropdownItems.value.map((i) => i.name)).toEqual([
'output_001.png [output]',
'output_002.png [output]',
'output_003.png [output]'
])
})
it('shows preview when job has only one output', async () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-2', 'single.png', '3', 1)
]
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref('single.png')
})
)
filterSelected.value = 'outputs'
await nextTick()
expect(dropdownItems.value).toHaveLength(1)
expect(dropdownItems.value[0].name).toBe('single.png [output]')
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
})
it('resolves two multi-output jobs independently', async () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-A', 'previewA.png', '1', 2),
makeMultiOutputAsset('job-B', 'previewB.png', '2', 2)
]
mockResolveOutputAssetItems.mockImplementation(
async (meta: { jobId: string }) => {
if (meta.jobId === 'job-A') {
return [
{
id: 'A-1',
name: 'a1.png',
preview_url: '',
tags: ['output']
},
{
id: 'A-2',
name: 'a2.png',
preview_url: '',
tags: ['output']
}
]
}
return [
{
id: 'B-1',
name: 'b1.png',
preview_url: '',
tags: ['output']
},
{
id: 'B-2',
name: 'b2.png',
preview_url: '',
tags: ['output']
}
]
}
)
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref(undefined)
})
)
filterSelected.value = 'outputs'
await vi.waitFor(() => {
expect(dropdownItems.value).toHaveLength(4)
})
const names = dropdownItems.value.map((i) => i.name)
expect(names).toContain('a1.png [output]')
expect(names).toContain('a2.png [output]')
expect(names).toContain('b1.png [output]')
expect(names).toContain('b2.png [output]')
})
it('resolves outputs when allOutputs already contains all items', async () => {
mockMediaAssets.media.value = [
{
id: 'job-complete',
name: 'preview.png',
preview_url: '/api/view?filename=preview.png&type=output',
tags: ['output'],
user_metadata: {
jobId: 'job-complete',
nodeId: '1',
subfolder: '',
outputCount: 2,
allOutputs: [
{
filename: 'out1.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
{
filename: 'out2.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
]
}
}
]
mockResolveOutputAssetItems.mockResolvedValue([
{
id: 'c-1',
name: 'out1.png',
preview_url: '',
tags: ['output']
},
{
id: 'c-2',
name: 'out2.png',
preview_url: '',
tags: ['output']
}
])
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref(undefined)
})
)
filterSelected.value = 'outputs'
await vi.waitFor(() => {
expect(dropdownItems.value).toHaveLength(2)
})
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
expect.objectContaining({ jobId: 'job-complete' }),
expect.any(Object)
)
const names = dropdownItems.value.map((i) => i.name)
expect(names).toEqual(['out1.png [output]', 'out2.png [output]'])
})
it('falls back to preview when resolver rejects', async () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-fail', 'preview.png', '1', 3)
]
mockResolveOutputAssetItems.mockRejectedValue(new Error('network error'))
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref(undefined)
})
)
filterSelected.value = 'outputs'
await vi.waitFor(() => {
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to resolve multi-output job',
'job-fail',
expect.any(Error)
)
})
expect(dropdownItems.value).toHaveLength(1)
expect(dropdownItems.value[0].name).toBe('preview.png [output]')
consoleWarnSpy.mockRestore()
})
})
describe('selectedSet', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns empty set when modelValue is undefined', () => {
const { selectedSet } = useWidgetSelectItems(
createDefaultOptions({
modelValue: ref(undefined)
})
)
expect(selectedSet.value.size).toBe(0)
})
it('returns set with matching item id when modelValue matches', () => {
const { selectedSet } = useWidgetSelectItems(
createDefaultOptions({
modelValue: ref('img_001.png')
})
)
expect(selectedSet.value.size).toBe(1)
expect(selectedSet.value.has('input-0')).toBe(true)
})
it('returns set with missing item id when modelValue matches no input', () => {
const { selectedSet } = useWidgetSelectItems(
createDefaultOptions({
modelValue: ref('nonexistent.png'),
values: () => ['img_001.png']
})
)
expect(selectedSet.value.size).toBe(1)
expect(selectedSet.value.has('missing-nonexistent.png')).toBe(true)
})
})
})

View File

@@ -1,314 +0,0 @@
import { capitalize } from 'es-toolkit'
import { computed, ref, shallowRef, toValue, watch } from 'vue'
import type { MaybeRefOrGetter, Ref } from 'vue'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
import {
filterItemByBaseModels,
filterItemByOwnership
} from '@/platform/assets/utils/assetFilterUtils'
import {
getAssetBaseModels,
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import type {
FilterOption,
OwnershipOption
} from '@/platform/assets/types/filterTypes'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import type { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import type { AssetKind } from '@/types/widgetTypes'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
function getDisplayLabel(
value: string,
getOptionLabel?: ((value?: string | null) => string) | undefined
): string {
if (!getOptionLabel) return value
try {
return getOptionLabel(value) || value
} catch (e) {
console.warn('Failed to map value:', e)
return value
}
}
function assetKindToMediaType(kind: AssetKind): string {
return kind === 'mesh' ? '3D' : kind
}
function getMediaUrl(
filename: string,
type: 'input' | 'output',
assetKind: AssetKind | undefined
): string {
if (!['image', 'video', 'audio', 'mesh'].includes(assetKind ?? '')) return ''
const params = new URLSearchParams({ filename, type })
appendCloudResParam(params, filename)
return `/api/view?${params}`
}
interface UseWidgetSelectItemsOptions {
values: MaybeRefOrGetter<unknown[] | undefined>
getOptionLabel: MaybeRefOrGetter<
((value?: string | null) => string) | undefined
>
modelValue: Ref<string | undefined>
assetKind: MaybeRefOrGetter<AssetKind | undefined>
outputMediaAssets: ReturnType<typeof useMediaAssets>
assetData: ReturnType<typeof useAssetWidgetData> | null
isAssetMode: MaybeRefOrGetter<boolean | undefined>
}
export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
const { modelValue, outputMediaAssets, assetData } = options
const filterSelected = ref('all')
const filterOptions = computed<FilterOption[]>(() => {
const isAsset = toValue(options.isAssetMode)
if (isAsset) {
const categoryName = assetData?.category.value ?? 'All'
return [{ name: capitalize(categoryName), value: 'all' }]
}
return [
{ name: 'All', value: 'all' },
{ name: 'Inputs', value: 'inputs' },
{ name: 'Outputs', value: 'outputs' }
]
})
const ownershipSelected = ref<OwnershipOption>('all')
const showOwnershipFilter = computed(() => !!toValue(options.isAssetMode))
const { ownershipOptions, availableBaseModels } = useAssetFilterOptions(
() => assetData?.assets.value ?? []
)
const baseModelSelected = ref<Set<string>>(new Set())
const showBaseModelFilter = computed(() => !!toValue(options.isAssetMode))
const baseModelOptions = computed<FilterOption[]>(() => {
if (!toValue(options.isAssetMode) || !assetData) return []
return availableBaseModels.value
})
const resolvedByJobId = shallowRef(new Map<string, AssetItem[]>())
const pendingJobIds = new Set<string>()
watch(
() => outputMediaAssets.media.value,
(assets, _, onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true
pendingJobIds.clear()
})
for (const asset of assets) {
const meta = getOutputAssetMetadata(asset.user_metadata)
if (!meta) continue
const outputCount = meta.outputCount ?? meta.allOutputs?.length ?? 0
if (
outputCount <= 1 ||
resolvedByJobId.value.has(meta.jobId) ||
pendingJobIds.has(meta.jobId)
)
continue
pendingJobIds.add(meta.jobId)
void resolveOutputAssetItems(meta, { createdAt: asset.created_at })
.then((resolved) => {
if (cancelled || !resolved.length) return
const next = new Map(resolvedByJobId.value)
next.set(meta.jobId, resolved)
resolvedByJobId.value = next
})
.catch((error) => {
console.warn(
'Failed to resolve multi-output job',
meta.jobId,
error
)
})
.finally(() => {
pendingJobIds.delete(meta.jobId)
})
}
},
{ immediate: true }
)
const inputItems = computed<FormDropdownItem[]>(() => {
const values = toValue(options.values) || []
if (!Array.isArray(values)) return []
const labelFn = toValue(options.getOptionLabel)
const kind = toValue(options.assetKind)
return values.map((value, index) => ({
id: `input-${index}`,
preview_url: getMediaUrl(String(value), 'input', kind),
name: String(value),
label: getDisplayLabel(String(value), labelFn)
}))
})
const outputItems = computed<FormDropdownItem[]>(() => {
const kind = toValue(options.assetKind)
if (!['image', 'video', 'audio', 'mesh'].includes(kind ?? '')) return []
const targetMediaType = assetKindToMediaType(kind!)
const seen = new Set<string>()
const items: FormDropdownItem[] = []
const labelFn = toValue(options.getOptionLabel)
const assets = outputMediaAssets.media.value.flatMap((asset) => {
const meta = getOutputAssetMetadata(asset.user_metadata)
const resolved = meta ? resolvedByJobId.value.get(meta.jobId) : undefined
return resolved ?? [asset]
})
for (const asset of assets) {
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
if (seen.has(asset.id)) continue
seen.add(asset.id)
const annotatedPath = `${asset.name} [output]`
items.push({
id: `output-${asset.id}`,
preview_url:
asset.preview_url || getMediaUrl(asset.name, 'output', kind),
name: annotatedPath,
label: getDisplayLabel(annotatedPath, labelFn)
})
}
return items
})
const missingValueItem = computed<FormDropdownItem | undefined>(() => {
const currentValue = modelValue.value
if (!currentValue) return undefined
const labelFn = toValue(options.getOptionLabel)
const kind = toValue(options.assetKind)
if (toValue(options.isAssetMode) && assetData) {
const existsInAssets = assetData.assets.value.some(
(asset) => getAssetFilename(asset) === currentValue
)
if (existsInAssets) return undefined
return {
id: `missing-${currentValue}`,
preview_url: '',
name: currentValue,
label: getDisplayLabel(currentValue, labelFn)
}
}
const existsInInputs = inputItems.value.some(
(item) => item.name === currentValue
)
const existsInOutputs = outputItems.value.some(
(item) => item.name === currentValue
)
if (existsInInputs || existsInOutputs) return undefined
const isOutput = currentValue.endsWith(' [output]')
const strippedValue = isOutput
? currentValue.replace(' [output]', '')
: currentValue
return {
id: `missing-${currentValue}`,
preview_url: getMediaUrl(
strippedValue,
isOutput ? 'output' : 'input',
kind
),
name: currentValue,
label: getDisplayLabel(currentValue, labelFn)
}
})
const assetItems = computed<FormDropdownItem[]>(() => {
if (!toValue(options.isAssetMode) || !assetData) return []
return assetData.assets.value.map((asset) => ({
id: asset.id,
name: getAssetFilename(asset),
label: getAssetDisplayName(asset),
preview_url: asset.preview_url,
is_immutable: asset.is_immutable,
base_models: getAssetBaseModels(asset)
}))
})
const filteredAssetItems = computed<FormDropdownItem[]>(() =>
filterItemByBaseModels(
filterItemByOwnership(assetItems.value, ownershipSelected.value),
baseModelSelected.value
)
)
const allItems = computed<FormDropdownItem[]>(() => {
if (toValue(options.isAssetMode) && assetData) {
return filteredAssetItems.value
}
return [
...(missingValueItem.value ? [missingValueItem.value] : []),
...inputItems.value,
...outputItems.value
]
})
const dropdownItems = computed<FormDropdownItem[]>(() => {
if (toValue(options.isAssetMode)) {
return allItems.value
}
switch (filterSelected.value) {
case 'inputs':
return inputItems.value
case 'outputs':
return outputItems.value
case 'all':
default:
return allItems.value
}
})
const displayItems = computed<FormDropdownItem[]>(() => {
if (toValue(options.isAssetMode) && assetData && missingValueItem.value) {
return [missingValueItem.value, ...filteredAssetItems.value]
}
return dropdownItems.value
})
const selectedSet = computed<Set<string>>(() => {
const currentValue = modelValue.value
if (currentValue === undefined) return new Set()
const item = displayItems.value.find((item) => item.name === currentValue)
return item ? new Set([item.id]) : new Set()
})
return {
dropdownItems,
displayItems,
filterSelected,
filterOptions,
ownershipSelected,
showOwnershipFilter,
ownershipOptions,
baseModelSelected,
showBaseModelFilter,
baseModelOptions,
selectedSet
}
}

View File

@@ -1,47 +0,0 @@
/* eslint-disable vue/one-component-per-file */
import { describe, expect, it } from 'vitest'
import { render, screen, stubs } from '@/utils/test-utils'
import { defineComponent, h } from 'vue'
const TestButton = defineComponent({
props: { label: { type: String, required: true } },
setup(props) {
return () => h('button', { 'data-testid': 'test-btn' }, props.label)
}
})
describe('test-utils', () => {
it('renders a component with default plugins', () => {
render(TestButton, { props: { label: 'Click me' } })
expect(screen.getByTestId('test-btn')).toHaveTextContent('Click me')
})
it('provides a userEvent instance by default', () => {
const { user } = render(TestButton, { props: { label: 'Click' } })
expect(user).toBeDefined()
})
it('allows opting out of userEvent', () => {
const { user } = render(TestButton, {
props: { label: 'Click' },
setupUser: false
})
expect(user).toBeUndefined()
})
})
describe('stubs', () => {
describe('Skeleton', () => {
it('renders with data-testid', () => {
const Wrapper = defineComponent({
setup() {
return () => h(stubs.Skeleton)
}
})
render(Wrapper)
expect(screen.getByTestId('skeleton')).toBeDefined()
})
})
})

View File

@@ -1,135 +0,0 @@
/* eslint-disable vue/one-component-per-file, vue/require-prop-types */
import type { RenderOptions, RenderResult } from '@testing-library/vue'
import type { ComponentMountingOptions } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
/**
* Creates the default set of Vue plugins for component tests.
*
* - Pinia with `stubActions: false` (actions execute, but are spied)
* - vue-i18n with English locale
*
* Pass additional plugins via the `plugins` option in `renderWithDefaults`.
*/
function createDefaultPlugins() {
return [
createTestingPinia({ stubActions: false }),
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
]
}
/**
* Common directive stubs for components that use PrimeVue/custom directives.
* Prevents "Failed to resolve directive" warnings in test output.
*/
const defaultDirectiveStubs: Record<string, () => void> = {
tooltip: () => {}
}
/**
* PrimeVue component stubs for unit/component tests.
*
* Use via `global.stubs` in render options:
* ```ts
* render(MyComponent, { global: { stubs: { Skeleton: stubs.Skeleton } } })
* ```
*
* Or use `renderWithDefaults` which provides plugins and directives.
* Import `stubs` and pass them via `global.stubs` when needed.
*/
const SkeletonStub = defineComponent({
name: 'Skeleton',
setup() {
return () => h('div', { 'data-testid': 'skeleton' })
}
})
const TagStub = defineComponent({
name: 'Tag',
props: ['value', 'severity'],
setup(props, { slots }) {
return () =>
h('span', { 'data-testid': 'tag' }, slots.default?.() ?? props.value)
}
})
const BadgeStub = defineComponent({
name: 'Badge',
props: ['value', 'severity'],
setup(props) {
return () => h('span', { 'data-testid': 'badge' }, props.value)
}
})
const MessageStub = defineComponent({
name: 'Message',
props: ['severity', 'closable'],
setup(_, { slots }) {
return () =>
h('div', { 'data-testid': 'message', role: 'alert' }, slots.default?.())
}
})
const stubs = {
Skeleton: SkeletonStub,
Tag: TagStub,
Badge: BadgeStub,
Message: MessageStub
} as const
type RenderWithDefaultsResult = RenderResult & {
user: ReturnType<typeof userEvent.setup> | undefined
}
/**
* Renders a Vue component with standard test infrastructure pre-configured:
* - Pinia testing store (actions execute but are spied)
* - vue-i18n with English messages
* - Common directive stubs (tooltip)
* - Optional userEvent instance
*
* @example
* ```ts
* import { render, screen } from '@/utils/test-utils'
*
* it('renders button text', async () => {
* const { user } = render(MyComponent, { props: { label: 'Click' } })
* expect(screen.getByRole('button')).toHaveTextContent('Click')
* await user!.click(screen.getByRole('button'))
* })
* ```
*/
function renderWithDefaults<C>(
component: C,
options?: ComponentMountingOptions<C> & { setupUser?: boolean }
): RenderWithDefaultsResult {
const { setupUser = true, global: globalOptions, ...rest } = options ?? {}
const user = setupUser ? userEvent.setup() : undefined
const result = render<C>(component, {
global: {
...globalOptions,
plugins: [...createDefaultPlugins(), ...(globalOptions?.plugins ?? [])],
directives: {
...defaultDirectiveStubs,
...globalOptions?.directives
}
},
...rest
} as RenderOptions<C>)
return { ...result, user }
}
export { renderWithDefaults as render, screen, stubs }