Compare commits

...

4 Commits

Author SHA1 Message Date
christian-byrne
9f641ad319 [release] Increment version to 1.44.3 2026-04-12 00:08:35 +00:00
Alexander Brown
6f579c5992 fix: enable playwright/no-force-option lint rule (#11164)
## Summary

Enable the previously disabled `playwright/no-force-option` lint rule at
error level and resolve all 29 violations across 10 files.

## Changes

### Lint rule
- `.oxlintrc.json`: `playwright/no-force-option` changed from `off` to
`error`

### Shared utility
- `CanvasHelper.ts`: Add `mouseClickAt()` and `mouseDblclickAt()`
methods that convert canvas-element-relative positions to absolute page
coordinates and use `page.mouse` APIs, avoiding Playwright's locator
actionability checks that fail when Vue DOM overlays sit above the
`<canvas>` element

### Force removal (20 violations)
- `selectionToolboxActions.spec.ts`: Remove `force: true` from 8 toolbox
button clicks (the `pointer-events: none` splitter overlay does not
intercept `elementFromPoint()`)
- `selectionToolboxSubmenus.spec.ts`: Remove `force: true` from 2
popover menu item clicks
- `BuilderSelectHelper.ts`: Remove `force: true` from 2 widget/node
clicks (builder mode does not disable pointer events)
- `linkInteraction.spec.ts`: Remove `force: true` from 3 slot `dragTo()`
calls (`::after` pseudo-elements do not intercept `elementFromPoint()`)
- `SidebarTab.ts`: Remove `force: true` from toast dismissal (`.catch()`
already handles failures)
- `nodeHelp.spec.ts`: Remove `force: true` from info button click
(preceding `toBeVisible()` assertion is sufficient)

### Rewrites (3 violations)
- `integerWidget.spec.ts`: Replace force-clicking disabled buttons with
`toBeDisabled()` assertions
- `Topbar.ts`: Replace force-click with `waitFor({ state: 'visible' })`
after hover

### Canvas coordinate clicks (9 violations)
- `litegraphUtils.ts`: Convert `NodeReference.click()` and
`navigateIntoSubgraph()` to use
`canvasOps.mouseClickAt()`/`mouseDblclickAt()`
- `subgraphPromotion.spec.ts`: Convert 3 right-click canvas calls to
`canvasOps.mouseClickAt()`
- `selectionToolboxSubmenus.spec.ts`: Convert 1 canvas dismiss-click to
`canvasOps.mouseClickAt()`

## Rationale

The original `force: true` usages were added defensively based on
incorrect assumptions about the `z-999 pointer-events: none` splitter
overlay intercepting Playwright's actionability checks. In reality,
`elementFromPoint()` skips elements with `pointer-events: none`, so the
overlay is transparent to Playwright's hit-test.

For canvas coordinate clicks, `force: true` on a locator does not tunnel
through DOM overlays — it only skips Playwright's preflight checks.
`page.mouse.click()` is the correct API for coordinate-based canvas
interactions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11164-fix-enable-playwright-no-force-option-lint-rule-33f6d73d365081e78601c6114121d272)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-11 19:59:34 +00:00
Dante
e729e5edb8 fix: place cloned node above original in Vue renderer (#10361)
## Summary

Cloned/pasted nodes in Node 2.0 (Vue renderer) mode now appear above the
original node instead of behind it.

## Root Cause

The legacy LiteGraph canvas renderer uses array ordering for z-ordering:
nodes are stored in `graph._nodes` and drawn sequentially, so newly
added nodes (appended to the end) are automatically drawn on top. There
is no explicit z-index.

The Vue renderer (Node 2.0) uses explicit CSS `z-index` for node
ordering. New nodes default to `zIndex: 0` in `layoutMutations.ts`. When
a node has been interacted with, `bringNodeToFront` raises its z-index.
A cloned node at z-index 0 therefore appears behind any previously
interacted node.

The alt-click clone path in `LGraphNode.vue` already handles this
correctly by calling `bringNodeToFront()` after cloning. However, the
menu clone and keyboard paste paths go through `_deserializeItems` in
`LGraphCanvas.ts`, which does not set z-index for new nodes.

| Clone method | Legacy renderer | Vue renderer (before fix) | Vue
renderer (after fix) |
|---|---|---|---|
| Alt-click drag | On top (array order) | On top (`bringNodeToFront`
called) | On top |
| Right-click menu Clone | On top (array order) | Behind original
(z-index 0) | On top |
| Ctrl+C / Ctrl+V | On top (array order) | Behind original (z-index 0) |
On top |

## Steps to Reproduce

1. Enable Node 2.0 mode (Vue renderer) in settings
2. Add any node to the canvas
3. Click or drag the node (raises its z-index via `bringNodeToFront`)
4. Right-click the node and select "Clone"
5. **Expected**: Cloned node appears above the original, immediately
draggable
6. **Actual**: Cloned node appears behind the original; user must move
the original to access the clone

## Changes

After `batchUpdateNodeBounds` in `_deserializeItems`, calls
`bringNodeToFront` for each newly created node so they receive a z-index
above all existing nodes.

## Side Effect Analysis

Checked all call sites of `_deserializeItems`:

1. **Initial graph load / workflow open**: `loadGraphData` in `app.ts`
does NOT call `_deserializeItems`. Workflow loading goes through
`LGraph.configure()` which directly adds nodes and links. The layout
store is initialized separately via `initializeFromLiteGraph`. No side
effect.

2. **Paste from clipboard (Ctrl+V)**: Both `usePaste.ts` (line 52) and
`pasteFromClipboard` (line 4080) call `_deserializeItems`. Pasted nodes
appearing on top is the correct and desired behavior. No issue.

3. **Undo/Redo**: `ChangeTracker.updateState()` calls
`app.loadGraphData()`, which does a full graph reconfigure -- it does
NOT go through `_deserializeItems`. No side effect.

4. **Subgraph blueprint addition**: `litegraphService.ts` (line 906)
calls `_deserializeItems` when adding subgraph blueprints from the node
library. These are freshly placed nodes that should appear on top.
Desired behavior.

5. **Alt-click clone in LGraphNode.vue**: This path calls
`LGraphCanvas.cloneNodes()` -> `_deserializeItems()`, then separately
calls `bringNodeToFront()` again on line 433 of `LGraphNode.vue`. The
second call is now redundant (the node is already at max z-index), but
harmless -- `bringNodeToFront` finds the current max, adds 1, and sets.
The z-index will increment from N to N+1 on the second call. This is a
minor redundancy, not a bug.

6. **Performance**: `bringNodeToFront` iterates all nodes in the layout
store once per call (O(m)) to find max z-index. For n new nodes, the
total cost is O(n*m). In practice, clone/paste operations involve a
small number of nodes (typically 1-10), so this is negligible. For
extremely large pastes (100+ nodes), each call also increments the max
by 1, so z-indices will be sequential (which is actually a reasonable
stacking order).

7. **layoutStore availability**: `layoutStore` is a module-level
singleton (`new LayoutStoreImpl()`) -- not a Pinia store -- so it is
always available. The `useLayoutMutations()` composable is a plain
function returning an object of closures over `layoutStore`. It does not
require Vue component context. No risk of runtime errors.

8. **Legacy renderer (non-Vue mode)**: When Node 2.0 mode is disabled,
the layout store still exists but is not used for rendering. Calling
`bringNodeToFront` will update z-index values in the Yjs document that
are never read. This is harmless.

## Red-Green Verification

| Commit | Result | Description |
|---|---|---|
| `6894b99` `test:` | RED | Test asserts cloned node z-index > original.
Fails with `expected 0 to be greater than 5`. |
| `3567469` `fix:` | GREEN | Calls `bringNodeToFront` for each new node
in `_deserializeItems`. Test passes. |

Fixes #10307

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-11 12:12:37 +00:00
Alexander Brown
3043b181d7 refactor: extract composables from VTU holdout components, complete VTL migration (#10966)
## Summary

Extract internal logic from the 2 remaining VTU holdout components into
composables, enabling full VTL migration.

## Changes

- **What**: Extract `useProcessedWidgets` from `NodeWidgets.vue`
(486→135 LOC) and `useWidgetSelectItems`/`useWidgetSelectActions` from
`WidgetSelectDropdown.vue` (563→170 LOC). Rewrite both component test
files as composable unit tests + slim behavioral VTL tests. Remove
`@vue/test-utils` devDependency.
- **Dependencies**: Removes `@vue/test-utils`

## Review Focus

- Composable extraction is mechanical — no logic changes, just moving
code into testable units
- `useProcessedWidgets` handles widget deduplication, promotion border
styling, error detection, and identity resolution (~290 LOC)
- `useWidgetSelectItems` handles the full computed chain from widget
values → dropdown items including cloud asset mode and multi-output job
resolution (~350 LOC)
- `useWidgetSelectActions` handles selection resolution and file upload
(~120 LOC)
- 40 new composable-level unit tests replace 13 `wrapper.vm.*` accesses
across the 2 holdout files

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10966-refactor-extract-composables-from-VTU-holdout-components-complete-VTL-migration-33c6d73d36508148a3a4ccf346722d6d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 19:04:16 -07:00
35 changed files with 2859 additions and 1730 deletions

View File

@@ -150,7 +150,7 @@
"playwright/no-element-handle": "error",
"playwright/no-eval": "error",
"playwright/no-focused-test": "error",
"playwright/no-force-option": "off",
"playwright/no-force-option": "error",
"playwright/no-networkidle": "error",
"playwright/no-page-pause": "error",
"playwright/no-skipped-test": "error",

View File

@@ -351,7 +351,7 @@ export class AssetsSidebarTab extends SidebarTab {
async dismissToasts() {
const closeButtons = this.page.locator('.p-toast-close-button')
for (const btn of await closeButtons.all()) {
await btn.click({ force: true }).catch(() => {})
await btn.click().catch(() => {})
}
// Wait for all toast elements to fully animate out and detach from DOM
await expect(this.page.locator('.p-toast-message'))

View File

@@ -71,7 +71,7 @@ export class Topbar {
async closeWorkflowTab(tabName: string) {
const tab = this.getWorkflowTab(tabName)
await tab.hover()
await tab.locator('.close-button').click({ force: true })
await tab.locator('.close-button').click()
}
getSaveDialog(): Locator {

View File

@@ -151,6 +151,7 @@ export class BuilderSelectHelper {
const widgetLocator = this.comfyPage.vueNodes
.getNodeLocator(String(nodeRef.id))
.getByLabel(widgetName, { exact: true })
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await widgetLocator.click({ force: true })
await this.comfyPage.nextFrame()
}
@@ -199,6 +200,7 @@ export class BuilderSelectHelper {
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
String(nodeRef.id)
)
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await nodeLocator.click({ force: true })
await this.comfyPage.nextFrame()
}

View File

@@ -74,6 +74,51 @@ export class CanvasHelper {
await this.nextFrame()
}
/**
* Convert a canvas-element-relative position to absolute page coordinates.
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
* cause Playwright's actionability check to fail on the canvas locator.
*/
private async toAbsolute(position: Position): Promise<Position> {
const box = await this.canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
return { x: box.x + position.x, y: box.y + position.y }
}
/**
* Click at canvas-element-relative coordinates using `page.mouse.click()`.
* Bypasses Playwright's actionability checks on the canvas locator, which
* can fail when Vue-rendered DOM nodes overlay the `<canvas>` element.
*/
async mouseClickAt(
position: Position,
options?: {
button?: 'left' | 'right' | 'middle'
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
}
): Promise<void> {
const abs = await this.toAbsolute(position)
const modifiers = options?.modifiers ?? []
for (const mod of modifiers) await this.page.keyboard.down(mod)
try {
await this.page.mouse.click(abs.x, abs.y, {
button: options?.button
})
} finally {
for (const mod of modifiers) await this.page.keyboard.up(mod)
}
await this.nextFrame()
}
/**
* Double-click at canvas-element-relative coordinates using `page.mouse`.
*/
async mouseDblclickAt(position: Position): Promise<void> {
const abs = await this.toAbsolute(position)
await this.page.mouse.dblclick(abs.x, abs.y)
await this.nextFrame()
}
async clickEmptySpace(): Promise<void> {
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
await this.nextFrame()

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
@@ -356,7 +355,11 @@ export class NodeReference {
}
async click(
position: 'title' | 'collapse',
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
options?: {
button?: 'left' | 'right' | 'middle'
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
moveMouseToEmptyArea?: boolean
}
) {
let clickPos: Position
switch (position) {
@@ -377,12 +380,7 @@ export class NodeReference {
delete options.moveMouseToEmptyArea
}
await this.comfyPage.canvas.click({
...options,
position: clickPos,
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt(clickPos, options)
if (moveMouseToEmptyArea) {
await this.comfyPage.canvasOps.moveMouseToEmptyArea()
}
@@ -499,31 +497,18 @@ export class NodeReference {
await expect(async () => {
// Try just clicking the enter button first
await this.comfyPage.canvas.click({
position: { x: 250, y: 250 },
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
await this.comfyPage.canvas.click({
position: subgraphButtonPos,
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt(subgraphButtonPos)
if (await checkIsInSubgraph()) return
for (const position of clickPositions) {
// Clear any selection first
await this.comfyPage.canvas.click({
position: { x: 250, y: 250 },
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
// Double-click to enter subgraph
await this.comfyPage.canvas.dblclick({ position, force: true })
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseDblclickAt(position)
if (await checkIsInSubgraph()) return
}

View File

@@ -29,7 +29,7 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
await expect(helpButton).toBeVisible()
await helpButton.click({ force: true })
await helpButton.click()
await comfyPage.nextFrame()
return comfyPage.page.getByTestId('properties-panel')

View File

@@ -49,7 +49,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const deleteButton = comfyPage.page.getByTestId('delete-button')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await deleteButton.click()
await comfyPage.nextFrame()
await expect
@@ -65,7 +65,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click({ force: true })
await infoButton.click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
@@ -98,7 +98,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const deleteButton = comfyPage.page.getByTestId('delete-button')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await deleteButton.click()
await comfyPage.nextFrame()
await expect
@@ -120,7 +120,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const bypassButton = comfyPage.page.getByTestId('bypass-button')
await expect(bypassButton).toBeVisible()
await bypassButton.click({ force: true })
await bypassButton.click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
@@ -128,7 +128,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
BYPASS_CLASS
)
await bypassButton.click({ force: true })
await bypassButton.click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
@@ -147,7 +147,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
'convert-to-subgraph-button'
)
await expect(convertButton).toBeVisible()
await convertButton.click({ force: true })
await convertButton.click()
await comfyPage.nextFrame()
// KSampler should be gone, replaced by a subgraph node
@@ -175,7 +175,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
'convert-to-subgraph-button'
)
await expect(convertButton).toBeVisible()
await convertButton.click({ force: true })
await convertButton.click()
await comfyPage.nextFrame()
await expect
@@ -200,13 +200,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
const frameButton = comfyPage.page.getByRole('button', {
name: /Frame Nodes/i
})
await expect(frameButton).toBeVisible()
await comfyPage.page
await expect(
comfyPage.selectionToolbox.getByRole('button', {
name: /Frame Nodes/i
})
).toBeVisible()
await comfyPage.selectionToolbox
.getByRole('button', { name: /Frame Nodes/i })
.click({ force: true })
.click()
await comfyPage.nextFrame()
await expect

View File

@@ -62,7 +62,7 @@ test.describe(
return
}
await moreOptionsBtn.click({ force: true })
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
@@ -126,9 +126,7 @@ test.describe(
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
await openMoreOptions(comfyPage)
await comfyPage.page
.getByText('Rename', { exact: true })
.click({ force: true })
await comfyPage.page.getByText('Rename', { exact: true }).click()
const input = comfyPage.page.locator(
'.group-title-editor.node-title-editor .editable-text input'
)
@@ -153,11 +151,7 @@ test.describe(
await comfyPage.nextFrame()
}
await comfyPage.page
.locator('#graph-canvas')
.click({ position: { x: 0, y: 50 }, force: true })
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt({ x: 0, y: 50 })
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeHidden()

View File

@@ -199,12 +199,7 @@ test.describe(
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
position: widgetPos,
button: 'right',
force: true
})
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
// Look for the Promote Widget menu entry
const promoteEntry = comfyPage.page
@@ -235,12 +230,7 @@ test.describe(
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
position: widgetPos,
button: 'right',
force: true
})
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
@@ -266,12 +256,7 @@ test.describe(
const stepsWidget2 = await ksampler2.getWidget(2)
const widgetPos2 = await stepsWidget2.getPosition()
await comfyPage.canvas.click({
position: widgetPos2,
button: 'right',
force: true
})
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt(widgetPos2, { button: 'right' })
const unpromoteEntry = comfyPage.page
.locator('.litemenu-entry')

View File

@@ -94,6 +94,7 @@ async function connectSlots(
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
const toLoc = slotLocator(page, to.nodeId, to.index, true)
await expectVisibleAll(fromLoc, toLoc)
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
await fromLoc.dragTo(toLoc, { force: true })
await nextFrame()
}
@@ -192,6 +193,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true)
await expectVisibleAll(outputSlot, inputSlot)
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
await outputSlot.dragTo(inputSlot, { force: true })
await comfyPage.nextFrame()
@@ -218,6 +220,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true)
await expectVisibleAll(outputSlot, inputSlot)
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
await outputSlot.dragTo(inputSlot, { force: true })
await comfyPage.nextFrame()

View File

@@ -22,10 +22,8 @@ test.describe('Vue Integer Widget', () => {
const initialValue = Number(await controls.input.inputValue())
// Verify widget is disabled when linked
await controls.incrementButton.click({ force: true })
await expect(controls.input).toHaveValue(initialValue.toString())
await controls.decrementButton.click({ force: true })
await expect(controls.incrementButton).toBeDisabled()
await expect(controls.decrementButton).toBeDisabled()
await expect(controls.input).toHaveValue(initialValue.toString())
await expect(seedWidget).toBeVisible()

View File

@@ -23,7 +23,7 @@ See `docs/testing/*.md` for detailed patterns.
## Component Testing
- Use Vue Test Utils for component tests
- Use `@testing-library/vue` with `@testing-library/user-event` for component tests (an ESLint rule bans `@vue/test-utils` in new 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 (also accepted)
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities (legacy; new tests must use @testing-library/vue)
- [Pinia](https://pinia.vuejs.org/cookbook/testing.html) - For store testing
## Getting Started

View File

@@ -1,5 +1,7 @@
# 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,6 +432,23 @@ 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

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

View File

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

View File

@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeLayout } from '@/renderer/core/layout/types'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
const TEST_NODE_TYPE = 'test/CloneZIndex' as const
class TestNode extends LGraphNode {
static override type = TEST_NODE_TYPE
constructor(title?: string) {
super(title ?? TEST_NODE_TYPE)
this.type = TEST_NODE_TYPE
}
}
function createCanvas(graph: LGraph): LGraphCanvas {
const el = document.createElement('canvas')
el.width = 800
el.height = 600
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} satisfies Partial<CanvasRenderingContext2D>
el.getContext = vi
.fn()
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
el.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
return new LGraphCanvas(el, graph, { skip_render: true })
}
function createLayoutEntry(node: LGraphNode, zIndex: number) {
const nodeId = String(node.id)
const layout: NodeLayout = {
id: nodeId,
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex,
visible: true,
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
}
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.Canvas,
actor: 'test'
})
}
function setZIndex(nodeId: string, zIndex: number, previousZIndex: number) {
layoutStore.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
nodeId,
zIndex,
previousZIndex,
timestamp: Date.now(),
source: LayoutSource.Canvas,
actor: 'test'
})
}
describe('cloned node z-index in Vue renderer', () => {
let graph: LGraph
let canvas: LGraphCanvas
let previousVueNodesMode: boolean
beforeEach(() => {
vi.clearAllMocks()
previousVueNodesMode = LiteGraph.vueNodesMode
LiteGraph.vueNodesMode = true
LiteGraph.registerNodeType(TEST_NODE_TYPE, TestNode)
graph = new LGraph()
canvas = createCanvas(graph)
LGraphCanvas.active_canvas = canvas
layoutStore.initializeFromLiteGraph([])
// Simulate Vue runtime: create layout entries when nodes are added
graph.onNodeAdded = (node: LGraphNode) => {
createLayoutEntry(node, 0)
}
})
afterEach(() => {
LiteGraph.vueNodesMode = previousVueNodesMode
})
it('places cloned nodes above the original node z-index', () => {
const originalNode = new TestNode()
originalNode.pos = [100, 100]
originalNode.size = [200, 100]
graph.add(originalNode)
const originalNodeId = String(originalNode.id)
setZIndex(originalNodeId, 5, 0)
const originalLayout = layoutStore.getNodeLayoutRef(originalNodeId).value
expect(originalLayout?.zIndex).toBe(5)
// Clone the node via cloneNodes (same path as right-click > clone)
const result = LGraphCanvas.cloneNodes([originalNode])
expect(result).toBeDefined()
expect(result!.created.length).toBe(1)
const clonedNode = result!.created[0] as LGraphNode
const clonedNodeId = String(clonedNode.id)
// The cloned node should have a z-index higher than the original
const clonedLayout = layoutStore.getNodeLayoutRef(clonedNodeId).value
expect(clonedLayout).toBeDefined()
expect(clonedLayout!.zIndex).toBeGreaterThan(originalLayout!.zIndex)
})
it('assigns distinct sequential z-indices when cloning multiple nodes', () => {
const nodeA = new TestNode()
nodeA.pos = [100, 100]
nodeA.size = [200, 100]
graph.add(nodeA)
setZIndex(String(nodeA.id), 3, 0)
const nodeB = new TestNode()
nodeB.pos = [400, 100]
nodeB.size = [200, 100]
graph.add(nodeB)
setZIndex(String(nodeB.id), 7, 0)
const result = LGraphCanvas.cloneNodes([nodeA, nodeB])
expect(result).toBeDefined()
expect(result!.created.length).toBe(2)
const clonedA = result!.created[0] as LGraphNode
const clonedB = result!.created[1] as LGraphNode
const layoutA = layoutStore.getNodeLayoutRef(String(clonedA.id)).value!
const layoutB = layoutStore.getNodeLayoutRef(String(clonedB.id)).value!
// Both cloned nodes should be above the highest original (z-index 7)
expect(layoutA.zIndex).toBeGreaterThan(7)
expect(layoutB.zIndex).toBeGreaterThan(7)
// Each cloned node should have a distinct z-index
expect(layoutA.zIndex).not.toBe(layoutB.zIndex)
})
})

View File

@@ -7,6 +7,7 @@ import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -4270,6 +4271,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
layoutStore.batchUpdateNodeBounds(newPositions)
// Bring cloned/pasted nodes to front so they render above the originals
const allNodes = layoutStore.getAllNodes().value
let maxZIndex = 0
for (const [, layout] of allNodes) {
if (layout.zIndex > maxZIndex) maxZIndex = layout.zIndex
}
const { setNodeZIndex } = useLayoutMutations()
for (let i = 0; i < newPositions.length; i++) {
setNodeZIndex(newPositions[i].nodeId, maxZIndex + i + 1)
}
this.selectItems(created)
forEachNode(graph, (n) => n.onGraphConfigured?.())
forEachNode(graph, (n) => n.onAfterGraphConfigured?.())

View File

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

View File

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

View File

@@ -1,9 +1,7 @@
/* 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'
@@ -13,7 +11,6 @@ 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', () => ({
@@ -98,35 +95,6 @@ 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()
@@ -155,19 +123,6 @@ 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', () => {
@@ -318,54 +273,6 @@ 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,56 +80,16 @@
</template>
<script setup lang="ts">
import type { TooltipOptions } from 'primevue'
import { computed, onErrorCaptured, ref, toValue } from 'vue'
import type { Component } from 'vue'
import { onErrorCaptured, ref } from 'vue'
import type {
SafeWidgetData,
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
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 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 { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { cn } from '@/utils/tailwindUtil'
import {
getExecutionIdFromNodeData,
getLocatorIdFromNodeData
} from '@/utils/graphTraversalUtil'
import { app } from '@/scripts/app'
import InputSlot from './InputSlot.vue'
@@ -141,12 +101,7 @@ 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
@@ -160,8 +115,6 @@ function handleBringToFront() {
}
}
const { handleNodeRightClick } = useNodeEventHandlers()
// Error boundary implementation
const renderError = ref<string | null>(null)
@@ -173,314 +126,11 @@ onErrorCaptured((error) => {
return false
})
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(' ')
})
const {
canSelectInputs,
gridTemplateRows,
nodeType,
processedWidgets,
showAdvanced
} = useProcessedWidgets(() => nodeData)
</script>

View File

@@ -0,0 +1,499 @@
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

@@ -0,0 +1,431 @@
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,17 +1,14 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { computed } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { computed, nextTick, ref } from 'vue'
import type { Ref } 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'
@@ -55,7 +52,7 @@ vi.mock(
})
)
const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
const { mockMediaAssets } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
@@ -68,8 +65,7 @@ const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
loadMore: vi.fn(),
hasMore: ref(false),
isLoadingMore: ref(false)
},
mockResolveOutputAssetItems: vi.fn()
}
}
})
@@ -78,732 +74,187 @@ vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
resolveOutputAssetItems: (...args: unknown[]) =>
mockResolveOutputAssetItems(...args)
resolveOutputAssetItems: vi.fn().mockResolvedValue([])
}))
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: {} }
})
interface WidgetSelectDropdownInstance extends ComponentPublicInstance {
inputItems: FormDropdownItem[]
outputItems: FormDropdownItem[]
dropdownItems: FormDropdownItem[]
filterSelected: string
updateSelectedItems: (selectedSet: Set<string>) => void
}
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,
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]
}
})
)
}
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'
)
]
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: [] }
})
describe('WidgetSelectDropdown', () => {
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()
mockAssetsData.items = []
mockItemsRef.value = []
mockSelectedSetRef.value = new Set()
mockFilterSelectedRef.value = 'all'
mockUpdateSelectedItems.mockClear()
mockHandleFilesUpdate.mockClear()
})
it('calls checkState after dropdown selection changes modelValue', () => {
function renderComponent(
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]
}
})
}
it('renders the dropdown component', () => {
mockItemsRef.value = [
{ id: 'input-0', name: 'img_001.png' },
{ id: 'input-1', name: 'photo_abc.jpg' }
]
mockSelectedSetRef.value = new Set(['input-0'])
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']
}
})
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()
renderComponent(widget, 'img_001.png')
expect(screen.getByText('img_001.png')).toBeDefined()
})
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)
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'])
const widget = createMockWidget<string | undefined>({
value: 'img_001.png',
name: 'test_image',
value: 'model_a.safetensors',
name: 'test_model',
type: 'combo',
options: { values: ['img_001.png'] }
options: {
values: [],
nodeType: 'CheckpointLoaderSimple'
}
})
const wrapper = mountForUndo(widget, 'img_001.png')
renderComponent(widget, 'model_a.safetensors', {
assetKind: 'model',
isAssetMode: true,
nodeType: 'CheckpointLoaderSimple'
})
expect(screen.getByText('model_a.safetensors')).toBeDefined()
})
const file = new File(['test'], 'uploaded.png', { type: 'image/png' })
await wrapper.vm.handleFilesUpdate([file])
describe('composable wiring', () => {
const items: FormDropdownItem[] = [
{ id: 'input-0', name: 'cat.png', label: 'cat.png' },
{ id: 'input-1', name: 'dog.png', label: 'dog.png' }
]
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['uploaded.png'])
expect(mockCheckState).toHaveBeenCalledOnce()
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()
})
})
})

View File

@@ -1,45 +1,20 @@
<script setup lang="ts">
import { capitalize } from 'es-toolkit'
import { computed, provide, ref, shallowRef, toRef, watch } from 'vue'
import { computed, provide, ref, toRef } 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 {
FormDropdownItem,
LayoutMode
} from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { 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 { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { useWidgetSelectActions } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions'
import { useWidgetSelectItems } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems'
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
@@ -71,7 +46,6 @@ const modelValue = defineModel<string | undefined>({
})
const { t } = useI18n()
const toastStore = useToastStore()
const outputMediaAssets = useMediaAssets('output')
@@ -92,261 +66,34 @@ const getAssetData = () => {
}
const assetData = getAssetData()
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 {
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 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 { updateSelectedItems, handleFilesUpdate } = useWidgetSelectActions({
modelValue,
dropdownItems,
widget: () => props.widget,
uploadFolder: () => props.uploadFolder,
uploadSubfolder: () => props.uploadSubfolder
})
const mediaPlaceholder = computed(() => {
@@ -392,141 +139,12 @@ const acceptTypes = computed(() => {
case 'mesh':
return SUPPORTED_EXTENSIONS_ACCEPT
default:
return undefined // model or unknown
return undefined
}
})
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()
@@ -537,11 +155,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

@@ -0,0 +1,229 @@
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

@@ -0,0 +1,120 @@
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

@@ -0,0 +1,668 @@
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

@@ -0,0 +1,314 @@
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
}
}