Compare commits

...

8 Commits

Author SHA1 Message Date
Alexander Brown
a7e9ea9070 Revert "feat: enable new queue progress by default (#8121)"
This reverts commit 0439f744b9.
2026-01-29 15:49:18 -08:00
Rizumu Ayaka
fc38f16543 fix: default image input for the template is displayed as empty on dropdown selection (#8276)
The image input for nodes loaded from templates appears empty in the
Properties Panel.

When the widget's current value (saved in the template) is not in the
available file list returned by the server, the selectedSet is empty,
causing a placeholder to be displayed instead of the actual value.

Added a missingValueItem computed property in WidgetSelectDropdown.vue.
When the current value is not in inputItems or outputItems, it creates a
fallback item and adds it to dropdownItems.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8276-fix-default-image-input-for-the-template-is-displayed-as-empty-on-dropdown-selection-2f16d73d3650817eaad5e4e33637fb74)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-29 14:19:52 -08:00
Christian Byrne
faad2c03de feat: increase allowed batch count (on Run button) on cloud (from 4 to 32) (#8436)
## Summary

cloud.comfy.org now suports up to 100 queued jobs at a time
([details](https://x.com/ComfyUI/status/2016622139722572032?s=20)). We
can increase the batch count limit to 32. Possible downside is cloud
having to reject larger number of jobs over the 100 limit if someone go
to 32 and clicks 4+ times. This setting was configurable anyway before,
so this is mostly a QoL change.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8436-feat-increase-allowed-batch-count-on-Run-button-on-cloud-from-4-to-32-2f76d73d365081728650fabefc394046)
by [Unito](https://www.unito.io)
2026-01-29 13:54:52 -08:00
Christian Byrne
d4cec49db5 fix: use merge-multiple for snapshot artifact download (#8432)
## Summary

Fixes the snapshot merge failure introduced by PR #8377
(actions/download-artifact v4→v7 upgrade).

## Root Cause

The v5+ release of `download-artifact` changed behavior: when a
`pattern` matches only a **single artifact**, files are extracted
directly to `path/` without the artifact name subdirectory. When only
one shard had changes, the merge loop couldn't find the expected
`snapshots-shard-*/` directories.

## Fix

Use `merge-multiple: true` — the documented pattern for combining
sharded artifacts. This merges all matched artifacts directly into the
target path, eliminating directory structure assumptions.

## Testing

This fix can be validated by re-running the workflow on [PR
#8276](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8276) after
merge.

---
- Fixes snapshot update workflow regression from #8377

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8432-fix-use-merge-multiple-for-snapshot-artifact-download-2f76d73d3650810b97fdfe28cd3c7694)
by [Unito](https://www.unito.io)

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 12:58:22 -08:00
AustinMroz
af8433fb3d Support widget specific contextmenu options in vue (#8431)
<img width="614" height="485" alt="image"
src="https://github.com/user-attachments/assets/2a635dec-8bed-4fab-9881-5e6057d482e1"
/>

These options were defined in `litegraphService`. While the existing
code for defining options is reused (to ensure there's no implementation
drift) these extra widget options use the litegraph format for context
menu options and do not belong in `useSelectionMenuOptions`. They have
been moved out of `useLitegraphService` (good), but left in
`litegraphService` (not great)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8431-Support-widget-specific-contextmenu-options-in-vue-2f76d73d3650814fb20fca352dc81e3b)
by [Unito](https://www.unito.io)
2026-01-29 11:38:41 -08:00
Johnpaul Chiwetelu
cabd08f0ec Road to No explicit any: Group 8 (part 6) test files (#8344)
## Summary

This PR removes unsafe type assertions ("as unknown as Type") from test
files and improves type safety across the codebase.

### Key Changes

#### Type Safety Improvements
- Removed all instances of "as unknown as" patterns from test files
- Used proper factory functions from litegraphTestUtils instead of
custom mocks
- Made incomplete mocks explicit using Partial<T> types
- Fixed DialogStore mocking with proper interface exports
- Improved type safety with satisfies operator where applicable

#### App Parameter Removal
- **Removed the unused `app` parameter from all ComfyExtension interface
methods**
- The app parameter was always undefined at runtime as it was never
passed from invokeExtensions
- Affected methods: init, setup, addCustomNodeDefs,
beforeRegisterNodeDef, beforeRegisterVueAppNodeDefs,
registerCustomNodes, loadedGraphNode, nodeCreated, beforeConfigureGraph,
afterConfigureGraph

##### Breaking Change Analysis
Verified via Sourcegraph that this is NOT a breaking change:
- Searched all 10 affected methods across GitHub repositories
- Only one external repository
([drawthingsai/draw-things-comfyui](https://github.com/drawthingsai/draw-things-comfyui))
declares the app parameter in their extension methods
- That repository never actually uses the app parameter (just declares
it in the function signature)
- All other repositories already omit the app parameter
- Search queries used:
- [init method
search](https://sourcegraph.com/search?q=context:global+repo:%5Egithub%5C.com/.*+lang:typescript+%22init%28app%22+-repo:Comfy-Org/ComfyUI_frontend&patternType=standard)
- [setup method
search](https://sourcegraph.com/search?q=context:global+repo:%5Egithub%5C.com/.*+lang:typescript+%22setup%28app%22+-repo:Comfy-Org/ComfyUI_frontend&patternType=standard)
  - Similar searches for all 10 methods confirmed no usage

### Files Changed

Test files:
-
src/components/settings/widgets/__tests__/WidgetInputNumberInput.test.ts
- src/services/keybindingService.escape.test.ts  
- src/services/keybindingService.forwarding.test.ts
- src/utils/__tests__/newUserService.test.ts →
src/utils/__tests__/useNewUserService.test.ts
- src/services/jobOutputCache.test.ts
-
src/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget.test.ts
-
src/renderer/extensions/vueNodes/widgets/composables/useIntWidget.test.ts
-
src/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.test.ts

Source files:
- src/types/comfy.ts - Removed app parameter from ComfyExtension
interface
- src/services/extensionService.ts - Improved type safety with
FunctionPropertyNames helper
- src/scripts/metadata/isobmff.ts - Fixed extractJson return type per
review
- src/extensions/core/*.ts - Updated extension implementations
- src/scripts/app.ts - Updated app initialization

### Testing
- All existing tests pass
- Type checking passes  
- ESLint/oxlint checks pass
- No breaking changes for external repositories

Part of the "Road to No Explicit Any" initiative.

### Previous PRs in this series:
- Part 2: #7401
- Part 3: #7935
- Part 4: #7970
- Part 5: #8064
- Part 6: #8083
- Part 7: #8092
- Part 8 Group 1: #8253
- Part 8 Group 2: #8258
- Part 8 Group 3: #8304
- Part 8 Group 4: #8314
- Part 8 Group 5: #8329
- Part 8 Group 6: #8344 (this PR)
2026-01-29 11:03:17 -08:00
AustinMroz
868180eb28 Disable logs button in sidebar on cloud (#8429)
Since cloud doesn't currently provide logs, this button was just causing
confusion.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8429-Disable-logs-button-in-sidebar-on-cloud-2f76d73d365081a4b909dd9a105e381e)
by [Unito](https://www.unito.io)
2026-01-29 09:58:25 -08:00
Alexander Brown
c51916d103 Chore: Add workflow dispatch to E2E (#8422)
Also remove old branches from the ignore

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8422-Chore-Add-workflow-dispatch-to-E2E-2f76d73d365081d0940dceaa37231ca7)
by [Unito](https://www.unito.io)
2026-01-29 08:47:18 -08:00
36 changed files with 494 additions and 209 deletions

View File

@@ -5,8 +5,8 @@ on:
push:
branches: [main, master, core/*, desktop/*]
pull_request:
branches-ignore:
[wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*]
branches-ignore: [wip/*, draft/*, temp/*]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}

View File

@@ -180,7 +180,7 @@ jobs:
with:
pattern: snapshots-shard-*
path: ./downloaded-snapshots
merge-multiple: false
merge-multiple: true
- name: List downloaded files
run: |
@@ -206,13 +206,13 @@ jobs:
echo "MERGING CHANGED SNAPSHOTS"
echo "=========================================="
# Check if any artifacts were downloaded
# Check if any artifacts were downloaded (merge-multiple puts files directly in path)
if [ ! -d "./downloaded-snapshots" ]; then
echo "No snapshot artifacts to merge"
echo "=========================================="
echo "MERGE COMPLETE"
echo "=========================================="
echo "Shards merged: 0"
echo "Files merged: 0"
exit 0
fi
@@ -222,37 +222,29 @@ jobs:
exit 1
fi
merged_count=0
# Count files to merge
file_count=$(find ./downloaded-snapshots -type f | wc -l)
# For each shard's changed files, copy them directly
for shard_dir in ./downloaded-snapshots/snapshots-shard-*/; do
if [ ! -d "$shard_dir" ]; then
continue
fi
if [ "$file_count" -eq 0 ]; then
echo "No snapshot files found in downloaded artifacts"
echo "=========================================="
echo "MERGE COMPLETE"
echo "=========================================="
echo "Files merged: 0"
exit 0
fi
shard_name=$(basename "$shard_dir")
file_count=$(find "$shard_dir" -type f | wc -l)
echo "Merging $file_count snapshot file(s)..."
if [ "$file_count" -eq 0 ]; then
echo " $shard_name: no files"
continue
fi
echo "Processing $shard_name ($file_count file(s))..."
# Copy files directly, preserving directory structure
# Since files are already in correct structure (no browser_tests/ prefix), just copy them all
cp -v -r "$shard_dir"* browser_tests/ 2>&1 | sed 's/^/ /'
merged_count=$((merged_count + 1))
echo " ✓ Merged"
echo ""
done
# Copy all files directly, preserving directory structure
# With merge-multiple: true, files are directly in ./downloaded-snapshots/ without shard subdirs
cp -v -r ./downloaded-snapshots/* browser_tests/ 2>&1 | sed 's/^/ /'
echo ""
echo "=========================================="
echo "MERGE COMPLETE"
echo "=========================================="
echo "Shards merged: $merged_count"
echo "Files merged: $file_count"
- name: Show changes
run: |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -41,7 +41,7 @@
:is-small="isSmall"
/>
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
<SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
<ModeToggle
@@ -65,6 +65,7 @@ import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPa
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'

View File

@@ -1,8 +1,9 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { getExtraOptionsForWidget } from '@/services/litegraphService'
import { isLGraphGroup } from '@/utils/litegraphUtil'
import {
@@ -45,6 +46,8 @@ export enum BadgeVariant {
// Global singleton for NodeOptions component reference
let nodeOptionsInstance: null | NodeOptionsInstance = null
const hoveredWidgetName = ref<string>()
/**
* Toggle the node options popover
* @param event - The trigger event
@@ -61,6 +64,13 @@ export function toggleNodeOptions(event: Event) {
* @param event - The trigger event (must be MouseEvent for position)
*/
export function showNodeOptions(event: MouseEvent) {
hoveredWidgetName.value = undefined
const target = event.target
if (target instanceof HTMLElement) {
const widgetEl = target.closest('.lg-node-widget')
if (widgetEl instanceof HTMLElement)
hoveredWidgetName.value = widgetEl.dataset.widgetName
}
if (nodeOptionsInstance?.show) {
nodeOptionsInstance.show(event)
}
@@ -133,8 +143,8 @@ export function useMoreOptionsMenu() {
} = useGroupMenuOptions()
const {
getBasicSelectionOptions,
getSubgraphOptions,
getMultipleNodesOptions
getMultipleNodesOptions,
getSubgraphOptions
} = useSelectionMenuOptions()
const hasSubgraphs = hasSubgraphsComputed
@@ -164,13 +174,13 @@ export function useMoreOptionsMenu() {
// For single node selection, also get LiteGraph menu items to merge
const litegraphOptions: MenuOption[] = []
const node: LGraphNode | undefined = selectedNodes.value[0]
if (
selectedNodes.value.length === 1 &&
!groupContext &&
canvasStore.canvas
) {
try {
const node = selectedNodes.value[0]
const rawItems = canvasStore.canvas.getNodeMenuOptions(node)
// Don't apply structuring yet - we'll do it after merging with Vue options
litegraphOptions.push(
@@ -249,6 +259,18 @@ export function useMoreOptionsMenu() {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
options.push({ type: 'divider' })
}
const rawName = hoveredWidgetName.value
const widget = node?.widgets?.find((w) => w.name === rawName)
if (widget) {
const widgetOptions = convertContextMenuToOptions(
getExtraOptionsForWidget(node, widget)
)
if (widgetOptions) {
options.push(...widgetOptions)
options.push({ type: 'divider' })
}
}
// Section 6 & 7: Extensions and Delete are handled by buildStructuredMenu
// Mark all Vue options with source

View File

@@ -105,6 +105,7 @@ export function addWidgetPromotionOptions(
content: `Promote Widget: ${widget.label ?? widget.name}`,
callback: () => {
promoteWidget(node, widget, promotableParents)
widget.callback?.(widget.value)
}
})
else {
@@ -112,6 +113,7 @@ export function addWidgetPromotionOptions(
content: `Un-Promote Widget: ${widget.label ?? widget.name}`,
callback: () => {
demoteWidget(node, widget, parents)
widget.callback?.(widget.value)
}
})
}

View File

@@ -187,7 +187,7 @@ export class ClipspaceDialog extends ComfyDialog {
app.registerExtension({
name: 'Comfy.Clipspace',
init(app) {
init() {
app.openClipspace = function () {
if (!ClipspaceDialog.instance) {
ClipspaceDialog.instance = new ClipspaceDialog()

View File

@@ -13,7 +13,7 @@ import { getWidgetConfig, mergeIfValid, setWidgetConfig } from './widgetInputs'
app.registerExtension({
name: 'Comfy.RerouteNode',
registerCustomNodes(app) {
registerCustomNodes() {
interface RerouteNode extends LGraphNode {
__outputType?: string | number
}

View File

@@ -21,7 +21,7 @@ const saveNodeTypes = new Set([
app.registerExtension({
name: 'Comfy.SaveImageExtraOutput',
async beforeRegisterNodeDef(nodeType, nodeData, app) {
async beforeRegisterNodeDef(nodeType, nodeData) {
if (saveNodeTypes.has(nodeData.name)) {
const onNodeCreated = nodeType.prototype.onNodeCreated
// When the SaveImage node is created we want to override the serialization of the output name widget to run our S&R

View File

@@ -511,7 +511,7 @@ export function mergeIfValid(
app.registerExtension({
name: 'Comfy.WidgetInputs',
async beforeRegisterNodeDef(nodeType, _nodeData, app) {
async beforeRegisterNodeDef(nodeType, _nodeData) {
// @ts-expect-error adding extra property
nodeType.prototype.convertWidgetToInput = function (this: LGraphNode) {
console.warn(

View File

@@ -603,7 +603,7 @@ export const CORE_SETTINGS: SettingParams[] = [
tooltip:
'The maximum number of tasks added to the queue at one button click',
type: 'number',
defaultValue: isCloud ? 4 : 100,
defaultValue: isCloud ? 32 : 100,
versionAdded: '1.3.5'
},
{
@@ -1173,12 +1173,10 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.Queue.QPOV2',
category: ['Comfy', 'Queue', 'Layout'],
name: 'Use the unified job queue in the Assets side panel',
type: 'boolean',
tooltip:
'Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout.',
defaultValue: true,
name: 'Queue Panel V2',
type: 'hidden',
tooltip: 'Enable the new Assets Panel design with list/grid view toggle',
defaultValue: false,
experimental: true
},
{

View File

@@ -76,7 +76,7 @@ vi.mock(
executing: computed(() => mockData.mockExecuting),
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any),
progressState: computed(() => undefined),
executionState: computed(() => 'idle' as const)
}))
})

View File

@@ -30,6 +30,7 @@
(!widget.simplified.options?.advanced || showAdvanced)
"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
:data-widget-name="widget.name"
>
<!-- Widget Input Slot Dot -->
<div

View File

@@ -290,7 +290,7 @@ describe('WidgetGalleria Image Display', () => {
await galleria.vm.$emit('update:activeIndex', 2)
// Check that the internal activeIndex ref was updated
const vm = wrapper.vm as any
const vm = wrapper.vm as typeof wrapper.vm & { activeIndex: number }
expect(vm.activeIndex).toBe(2)
})
})

View File

@@ -197,13 +197,12 @@ describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
describe('WidgetInputNumberInput Edge Cases for Precision Handling', () => {
it('handles null/undefined model values gracefully', () => {
const widget = createMockWidget(0, 'int')
// Mount with undefined as modelValue
const wrapper = mount(WidgetInputNumberInput, {
global: { plugins: [i18n] },
props: {
widget,
modelValue: undefined as any
}
modelValue: undefined
} as { widget: SimplifiedWidget<number>; modelValue: number | undefined }
})
expect(wrapper.findAll('button').length).toBe(2)

View File

@@ -138,6 +138,7 @@ function updateValueBy(delta: number) {
const dragValue = ref<number>()
const dragDelta = ref(0)
function handleMouseDown(e: PointerEvent) {
if (e.button > 0) return
if (props.widget.options?.disabled) return
const { target } = e
if (!(target instanceof HTMLElement)) return

View File

@@ -152,6 +152,50 @@ describe('WidgetSelectDropdown custom label mapping', () => {
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 = createMockWidget('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 undefined as unknown as string
}
return `Labeled: ${value}`
})
const widget = createMockWidget('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', () => {
@@ -171,4 +215,102 @@ describe('WidgetSelectDropdown custom label mapping', () => {
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 = createMockWidget('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 as unknown as { dropdownItems: DropdownItem[] }
).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 = createMockWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
const vmWithFilter = wrapper.vm as unknown as {
filterSelected: string
dropdownItems: DropdownItem[]
}
vmWithFilter.filterSelected = 'inputs'
await wrapper.vm.$nextTick()
const dropdownItems = vmWithFilter.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 = createMockWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
const vmWithFilter = wrapper.vm as unknown as {
filterSelected: string
dropdownItems: DropdownItem[]
outputItems: DropdownItem[]
}
vmWithFilter.filterSelected = 'outputs'
await wrapper.vm.$nextTick()
const dropdownItems = vmWithFilter.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 = createMockWidget('img_001.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'img_001.png')
const dropdownItems = (
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
).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 = createMockWidget(undefined as unknown as string, {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, undefined)
const dropdownItems = (
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
).dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
})
})

View File

@@ -85,14 +85,15 @@ const selectedSet = ref<Set<SelectedKey>>(new Set())
/**
* Transforms a value using getOptionLabel if available.
* Falls back to the original value if getOptionLabel is not provided or throws an error.
* 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)
return getOptionLabel(value) || value
} catch (e) {
console.error('Failed to map value:', e)
return value
@@ -146,11 +147,69 @@ const outputItems = computed<DropdownItem[]>(() => {
}))
})
/**
* 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<DropdownItem | undefined>(() => {
const currentValue = modelValue.value
if (!currentValue) return undefined
// Check in cloud mode assets
if (props.isAssetMode && assetData) {
const existsInAssets = assetData.dropdownItems.value.some(
(item) => item.name === currentValue
)
if (existsInAssets) return undefined
return {
id: `missing-${currentValue}`,
mediaSrc: '',
name: currentValue,
label: getDisplayLabel(currentValue),
metadata: ''
}
}
// 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}`,
mediaSrc: getMediaUrl(strippedValue, isOutput ? 'output' : 'input'),
name: currentValue,
label: getDisplayLabel(currentValue),
metadata: ''
}
})
const allItems = computed<DropdownItem[]>(() => {
if (props.isAssetMode && assetData) {
return assetData.dropdownItems.value
const items = assetData.dropdownItems.value
if (missingValueItem.value) {
return [missingValueItem.value, ...items]
}
return items
}
return [...inputItems.value, ...outputItems.value]
return [
...(missingValueItem.value ? [missingValueItem.value] : []),
...inputItems.value,
...outputItems.value
]
})
const dropdownItems = computed<DropdownItem[]>(() => {
@@ -165,7 +224,7 @@ const dropdownItems = computed<DropdownItem[]>(() => {
return outputItems.value
case 'all':
default:
return [...inputItems.value, ...outputItems.value]
return allItems.value
}
})

View File

@@ -8,7 +8,7 @@ describe('FormSelectButton Core Component', () => {
// Type-safe helper for mounting component
const mountComponent = (
modelValue: string | null | undefined = null,
options: (string | number | Record<string, any>)[] = [],
options: unknown[] = [],
props: Record<string, unknown> = {}
) => {
return mount(FormSelectButton, {
@@ -17,7 +17,11 @@ describe('FormSelectButton Core Component', () => {
},
props: {
modelValue,
options: options as any,
options: options as (
| string
| number
| { label: string; value: string | number }
)[],
...props
}
})
@@ -474,7 +478,7 @@ describe('FormSelectButton Core Component', () => {
})
it('handles mixed type options safely', () => {
const mixedOptions: any[] = [
const mixedOptions: unknown[] = [
'string',
123,
{ label: 'Object', value: 'obj' }

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { _for_testing } from '@/renderer/extensions/vueNodes/widgets/composables/useFloatWidget'
vi.mock('@/scripts/widgets', () => ({
@@ -16,14 +17,17 @@ const { onFloatValueChange } = _for_testing
describe('useFloatWidget', () => {
describe('onFloatValueChange', () => {
let widget: any
let widget: INumericWidget
beforeEach(() => {
// Reset the widget before each test
widget = {
type: 'number',
name: 'test_widget',
y: 0,
options: {},
value: 0
}
} as Partial<INumericWidget> as INumericWidget
})
it('should not round values when round option is not set', () => {

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { _for_testing } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget'
vi.mock('@/scripts/widgets', () => ({
@@ -16,14 +17,17 @@ const { onValueChange } = _for_testing
describe('useIntWidget', () => {
describe('onValueChange', () => {
let widget: any
let widget: INumericWidget
beforeEach(() => {
// Reset the widget before each test
widget = {
type: 'number',
name: 'test_widget',
y: 0,
options: {},
value: 0
}
} as Partial<INumericWidget> as INumericWidget
})
it('should round values based on step size', () => {

View File

@@ -3,19 +3,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { IWidget } from '@/lib/litegraph/src/litegraph'
import { api } from '@/scripts/api'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useRemoteWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
const createMockNode = (overrides: Partial<LGraphNode> = {}): LGraphNode => {
const node = new LGraphNode('TestNode')
Object.assign(node, overrides)
return node
function createMockWidget(overrides: Partial<IWidget> = {}): IWidget {
return {
name: 'test_widget',
type: 'text',
value: '',
options: {},
...overrides
} as Partial<IWidget> as IWidget
}
const createMockWidget = (overrides = {}): IWidget =>
({ ...overrides }) as unknown as IWidget
const mockCloudAuth = vi.hoisted(() => ({
isCloud: false,
authHeader: null as { Authorization: string } | null
@@ -67,7 +68,10 @@ function createMockConfig(overrides = {}): RemoteWidgetConfig {
const createMockOptions = (inputOverrides = {}) => ({
remoteConfig: createMockConfig(inputOverrides),
defaultValue: DEFAULT_VALUE,
node: createMockNode(),
node: createMockLGraphNode({
addWidget: vi.fn(() => createMockWidget()),
onRemoved: undefined
}),
widget: createMockWidget()
})
@@ -499,12 +503,14 @@ describe('useRemoteWidget', () => {
})
it('should handle rapid cache clearing during fetch', async () => {
let resolvePromise: (value: any) => void
const delayedPromise = new Promise((resolve) => {
resolvePromise = resolve
})
let resolvePromise: (value: { data: unknown; status?: number }) => void
const delayedPromise = new Promise<{ data: unknown; status?: number }>(
(resolve) => {
resolvePromise = resolve
}
)
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise as any)
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise)
const hook = useRemoteWidget(createMockOptions())
hook.getValue()
@@ -520,17 +526,20 @@ describe('useRemoteWidget', () => {
})
it('should handle widget destroyed during fetch', async () => {
let resolvePromise: (value: any) => void
const delayedPromise = new Promise((resolve) => {
resolvePromise = resolve
})
let resolvePromise: (value: { data: unknown; status?: number }) => void
const delayedPromise = new Promise<{ data: unknown; status?: number }>(
(resolve) => {
resolvePromise = resolve
}
)
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise as any)
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise)
let hook = useRemoteWidget(createMockOptions())
let hook: ReturnType<typeof useRemoteWidget> | null =
useRemoteWidget(createMockOptions())
const fetchPromise = hook.getValue()
hook = null as any
hook = null
resolvePromise!({ data: ['delayed data'] })
await fetchPromise
@@ -583,19 +592,19 @@ describe('useRemoteWidget', () => {
describe('auto-refresh on task completion', () => {
it('should add auto-refresh toggle widget', () => {
const mockNode = {
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {
})
const mockWidget = createMockWidget({
refresh: vi.fn()
}
})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
node: mockNode,
widget: mockWidget
})
// Should add auto-refresh toggle widget
@@ -613,19 +622,19 @@ describe('useRemoteWidget', () => {
it('should register event listener when enabled', async () => {
const addEventListenerSpy = vi.spyOn(api, 'addEventListener')
const mockNode = {
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {
})
const mockWidget = createMockWidget({
refresh: vi.fn()
}
})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
node: mockNode,
widget: mockWidget
})
// Event listener should be registered immediately
@@ -644,16 +653,16 @@ describe('useRemoteWidget', () => {
}
})
const mockNode = {
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {} as any
})
const mockWidget = createMockWidget({})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
node: mockNode,
widget: mockWidget
})
@@ -661,8 +670,9 @@ describe('useRemoteWidget', () => {
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
// Get the toggle callback and enable auto-refresh
const toggleCallback = mockNode.addWidget.mock.calls.find(
(call) => call[0] === 'toggle'
const addWidgetMock = mockNode.addWidget as ReturnType<typeof vi.fn>
const toggleCallback = addWidgetMock.mock.calls.find(
(call: unknown[]) => call[0] === 'toggle'
)?.[3]
toggleCallback?.(true)
@@ -681,16 +691,16 @@ describe('useRemoteWidget', () => {
}
})
const mockNode = {
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {} as any
})
const mockWidget = createMockWidget({})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
node: mockNode,
widget: mockWidget
})
@@ -715,20 +725,20 @@ describe('useRemoteWidget', () => {
const removeEventListenerSpy = vi.spyOn(api, 'removeEventListener')
const mockNode = {
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: [],
onRemoved: undefined as any
}
const mockWidget = {
onRemoved: undefined
})
const mockWidget = createMockWidget({
refresh: vi.fn()
}
})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
node: mockNode,
widget: mockWidget
})
// Simulate node removal

View File

@@ -1,10 +1,19 @@
import type { Mock } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
interface MockWebSocket {
readyState: number
send: Mock
close: Mock
addEventListener: Mock
removeEventListener: Mock
}
describe('API Feature Flags', () => {
let mockWebSocket: any
const wsEventHandlers: { [key: string]: (event: any) => void } = {}
let mockWebSocket: MockWebSocket
const wsEventHandlers: { [key: string]: (event: unknown) => void } = {}
beforeEach(() => {
// Use fake timers
@@ -16,7 +25,7 @@ describe('API Feature Flags', () => {
send: vi.fn(),
close: vi.fn(),
addEventListener: vi.fn(
(event: string, handler: (event: any) => void) => {
(event: string, handler: (event: unknown) => void) => {
wsEventHandlers[event] = handler
}
),

View File

@@ -919,8 +919,7 @@ export class ComfyApp {
const nodeDefArray: ComfyNodeDefV1[] = Object.values(allNodeDefs)
useExtensionService().invokeExtensions(
'beforeRegisterVueAppNodeDefs',
nodeDefArray,
this
nodeDefArray
)
nodeDefStore.updateNodeDefs(nodeDefArray)
}

View File

@@ -72,7 +72,11 @@ const findIsobmffBoxByType = (
return null
}
const extractJson = (data: Uint8Array, start: number, end: number): any => {
const extractJson = (
data: Uint8Array,
start: number,
end: number
): ComfyWorkflowJSON | ComfyApiWorkflow | null => {
let jsonStart = start
while (jsonStart < end && data[jsonStart] !== ASCII.OPEN_BRACE) {
jsonStart++
@@ -133,7 +137,7 @@ const extractMetadataValueFromDataBox = (
lowerKeyName === ComfyMetadataTags.PROMPT.toLowerCase() ||
lowerKeyName === ComfyMetadataTags.WORKFLOW.toLowerCase()
) {
return extractJson(data, valueStart, dataBoxEnd) || null
return extractJson(data, valueStart, dataBoxEnd)
}
return null
}

View File

@@ -28,7 +28,7 @@ type Props = {
style?: Partial<CSSStyleDeclaration>
for?: string
textContent?: string
[key: string]: any
[key: string]: unknown
}
type Children = Element[] | Element | string | string[]

View File

@@ -3,7 +3,6 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { KeybindingImpl, useKeybindingStore } from '@/stores/keybindingStore'
@@ -12,6 +11,8 @@ import { useWidgetStore } from '@/stores/widgetStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { ComfyExtension } from '@/types/comfy'
import type { AuthUserInfo } from '@/types/authTypes'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
export const useExtensionService = () => {
const extensionStore = useExtensionStore()
@@ -135,14 +136,28 @@ export const useExtensionService = () => {
}
}
type FunctionPropertyNames<T> = {
[K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? K : never
}[keyof T]
type RemoveLastAppParam<T> = T extends (
...args: [...infer Rest, ComfyApp]
) => infer R
? (...args: Rest) => R
: T
type ComfyExtensionParamsWithoutApp<T extends keyof ComfyExtension> =
RemoveLastAppParam<ComfyExtension[T]>
/**
* Invoke an extension callback
* @param {keyof ComfyExtension} method The extension callback to execute
* @param {any[]} args Any arguments to pass to the callback
* @param {unknown[]} args Any arguments to pass to the callback
* @returns
*/
const invokeExtensions = (method: keyof ComfyExtension, ...args: any[]) => {
const results: any[] = []
const invokeExtensions = <T extends FunctionPropertyNames<ComfyExtension>>(
method: T,
...args: Parameters<ComfyExtensionParamsWithoutApp<T>>
) => {
const results: ReturnType<ComfyExtension[T]>[] = []
for (const ext of extensionStore.enabledExtensions) {
if (method in ext) {
try {
@@ -164,12 +179,14 @@ export const useExtensionService = () => {
* Invoke an async extension callback
* Each callback will be invoked concurrently
* @param {string} method The extension callback to execute
* @param {...any} args Any arguments to pass to the callback
* @param {...unknown} args Any arguments to pass to the callback
* @returns
*/
const invokeExtensionsAsync = async (
method: keyof ComfyExtension,
...args: any[]
const invokeExtensionsAsync = async <
T extends FunctionPropertyNames<ComfyExtension>
>(
method: T,
...args: Parameters<ComfyExtensionParamsWithoutApp<T>>
) => {
return await Promise.all(
extensionStore.enabledExtensions.map(async (ext) => {

View File

@@ -9,8 +9,8 @@ vi.mock('@/services/providers/algoliaSearchProvider')
vi.mock('@/services/providers/registrySearchProvider')
describe('useRegistrySearchGateway', () => {
let consoleWarnSpy: any
let consoleInfoSpy: any
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
let consoleInfoSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()

View File

@@ -6,6 +6,7 @@ import type {
JobDetail,
JobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import {
findActiveIndex,
getJobDetail,
@@ -257,10 +258,12 @@ describe('jobOutputCache', () => {
priority: 0,
outputs: {}
}
const mockWorkflow = { version: 1 }
const mockWorkflow = { version: 1 } as Partial<ComfyWorkflowJSON>
vi.mocked(api.getJobDetail).mockResolvedValue(mockDetail)
vi.mocked(extractWorkflow).mockResolvedValue(mockWorkflow as any)
vi.mocked(extractWorkflow).mockResolvedValue(
mockWorkflow as ComfyWorkflowJSON
)
const result = await getJobWorkflow(jobId)

View File

@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import { useKeybindingService } from '@/services/keybindingService'
import { useCommandStore } from '@/stores/commandStore'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
import {
KeyComboImpl,
@@ -38,10 +39,13 @@ describe('keybindingService - Escape key handling', () => {
mockCommandExecute = vi.fn()
commandStore.execute = mockCommandExecute
// Reset dialog store mock to empty
// Reset dialog store mock to empty - only mock the properties we need for testing
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: []
} as any)
dialogStack: [],
// Add other required properties as undefined/default values to satisfy the type
// but they won't be used in these tests
...({} as Omit<ReturnType<typeof useDialogStore>, 'dialogStack'>)
})
keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
@@ -179,8 +183,10 @@ describe('keybindingService - Escape key handling', () => {
it('should not execute Escape keybinding when dialogs are open', async () => {
// Mock dialog store to have open dialogs
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: [{ key: 'test-dialog' }]
} as any)
dialogStack: [{ key: 'test-dialog' } as DialogInstance],
// Add other required properties as undefined/default values to satisfy the type
...({} as Omit<ReturnType<typeof useDialogStore>, 'dialogStack'>)
})
// Re-create keybinding service to pick up new mock
keybindingService = useKeybindingService()

View File

@@ -78,7 +78,9 @@ describe('keybindingService - Event Forwarding', () => {
// Reset dialog store mock to empty
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: []
} as any)
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
typeof useDialogStore
>)
keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
@@ -126,33 +128,35 @@ describe('keybindingService - Event Forwarding', () => {
})
it('should not forward Delete key when canvas processKey is not available', async () => {
// Temporarily replace processKey with undefined
// Temporarily replace processKey with undefined - testing edge case
const originalProcessKey = vi.mocked(app.canvas).processKey
vi.mocked(app.canvas).processKey = undefined as any
vi.mocked(app.canvas).processKey = undefined!
const event = createTestKeyboardEvent('Delete')
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
// Restore processKey for other tests
vi.mocked(app.canvas).processKey = originalProcessKey
try {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
} finally {
// Restore processKey for other tests
vi.mocked(app.canvas).processKey = originalProcessKey
}
})
it('should not forward Delete key when canvas is not available', async () => {
// Temporarily set canvas to null
const originalCanvas = vi.mocked(app).canvas
vi.mocked(app).canvas = null as any
vi.mocked(app).canvas = null!
const event = createTestKeyboardEvent('Delete')
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
// Restore canvas for other tests
vi.mocked(app).canvas = originalCanvas
try {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
} finally {
// Restore canvas for other tests
vi.mocked(app).canvas = originalCanvas
}
})
it('should not forward non-canvas keys', async () => {

View File

@@ -29,6 +29,7 @@ import type {
ISerialisableNodeOutput,
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -71,6 +72,49 @@ export interface HasInitialMinSize {
export const CONFIG = Symbol()
export const GET_CONFIG = Symbol()
export function getExtraOptionsForWidget(
node: LGraphNode,
widget: IBaseWidget
) {
const options: IContextMenuValue[] = []
const input = node.inputs.find((inp) => inp.widget?.name === widget.name)
if (input) {
options.unshift({
content: `${t('contextMenu.RenameWidget')}: ${widget.label ?? widget.name}`,
callback: async () => {
const newLabel = await useDialogService().prompt({
title: t('g.rename'),
message: t('g.enterNewName') + ':',
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
widget.label = newLabel || undefined
input.label = newLabel || undefined
widget.callback?.(widget.value)
useCanvasStore().canvas?.setDirty(true)
}
})
}
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const isFavorited = favoritedWidgetsStore.isFavorited(node, widget.name)
options.unshift({
content: isFavorited
? `${t('contextMenu.UnfavoriteWidget')}: ${widget.label ?? widget.name}`
: `${t('contextMenu.FavoriteWidget')}: ${widget.label ?? widget.name}`,
callback: () => {
favoritedWidgetsStore.toggleFavorite(node, widget.name)
}
})
if (node.graph && !node.graph.isRootGraph) {
addWidgetPromotionOptions(options, widget, node)
}
return options
}
/**
* Service that augments litegraph with ComfyUI specific functionality.
*/
@@ -678,47 +722,8 @@ export const useLitegraphService = () => {
}
const [x, y] = canvas.graph_mouse
const overWidget = this.getWidgetOnPos(x, y, true)
if (overWidget) {
const input = this.inputs.find(
(inp) => inp.widget?.name === overWidget.name
)
if (input) {
options.unshift({
content: `${t('contextMenu.RenameWidget')}: ${overWidget.label ?? overWidget.name}`,
callback: async () => {
const newLabel = await useDialogService().prompt({
title: t('g.rename'),
message: t('g.enterNewName') + ':',
defaultValue: overWidget.label,
placeholder: overWidget.name
})
if (newLabel === null) return
overWidget.label = newLabel || undefined
input.label = newLabel || undefined
useCanvasStore().canvas?.setDirty(true)
}
})
}
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const isFavorited = favoritedWidgetsStore.isFavorited(
this,
overWidget.name
)
options.unshift({
content: isFavorited
? `${t('contextMenu.UnfavoriteWidget')}: ${overWidget.label ?? overWidget.name}`
: `${t('contextMenu.FavoriteWidget')}: ${overWidget.label ?? overWidget.name}`,
callback: () => {
favoritedWidgetsStore.toggleFavorite(this, overWidget.name)
}
})
if (this.graph && !this.graph.isRootGraph) {
addWidgetPromotionOptions(options, overWidget, this)
}
}
if (overWidget)
options.unshift(...getExtraOptionsForWidget(this, overWidget))
return []
}
}
@@ -934,6 +939,7 @@ export const useLitegraphService = () => {
addNodeOnGraph,
addNodeInput,
getCanvasCenter,
getExtraOptionsForWidget,
goToNode,
resetView,
fitView,

View File

@@ -7,7 +7,7 @@ global.fetch = vi.fn()
global.URL = {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn()
} as any
} as Partial<typeof URL> as typeof URL
describe('mediaCacheService', () => {
describe('URL reference counting', () => {

View File

@@ -37,7 +37,7 @@ interface CustomDialogComponentProps {
export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &
CustomDialogComponentProps
interface DialogInstance<
export interface DialogInstance<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component

View File

@@ -134,18 +134,15 @@ export interface ComfyExtension {
actionBarButtons?: ActionBarButton[]
/**
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
* @param app The ComfyUI app instance
*/
init?(app: ComfyApp): Promise<void> | void
/**
* Allows any additional setup, called after the application is fully set up and running
* @param app The ComfyUI app instance
*/
setup?(app: ComfyApp): Promise<void> | void
/**
* Called before nodes are registered with the graph
* @param defs The collection of node definitions, add custom ones or edit existing ones
* @param app The ComfyUI app instance
*/
addCustomNodeDefs?(
defs: Record<string, ComfyNodeDef>,
@@ -155,7 +152,6 @@ export interface ComfyExtension {
// getCustomWidgets.
/**
* Allows the extension to add custom widgets
* @param app The ComfyUI app instance
* @returns An array of {[widget name]: widget data}
*/
getCustomWidgets?(app: ComfyApp): Promise<Widgets> | Widgets
@@ -185,7 +181,7 @@ export interface ComfyExtension {
* Allows the extension to add additional handling to the node before it is registered with **LGraph**
* @param nodeType The node class (not an instance)
* @param nodeData The original node object info config object
* @param app The ComfyUI app instance
* @param app The app instance
*/
beforeRegisterNodeDef?(
nodeType: typeof LGraphNode,
@@ -198,15 +194,13 @@ export interface ComfyExtension {
* Modifications is expected to be made in place.
*
* @param defs The node definitions
* @param app The ComfyUI app instance
* @param app The app instance
*/
beforeRegisterVueAppNodeDefs?(defs: ComfyNodeDef[], app: ComfyApp): void
/**
* Allows the extension to register additional nodes with LGraph after standard nodes are added.
* Custom node classes should extend **LGraphNode**.
*
* @param app The ComfyUI app instance
*/
registerCustomNodes?(app: ComfyApp): Promise<void> | void
/**
@@ -214,13 +208,13 @@ export interface ComfyExtension {
* If you break something in the backend and want to patch workflows in the frontend
* This is the place to do this
* @param node The node that has been loaded
* @param app The ComfyUI app instance
* @param app The app instance
*/
loadedGraphNode?(node: LGraphNode, app: ComfyApp): void
/**
* Allows the extension to run code after the constructor of the node
* @param node The node that has been created
* @param app The ComfyUI app instance
* @param app The app instance
*/
nodeCreated?(node: LGraphNode, app: ComfyApp): void
@@ -228,18 +222,22 @@ export interface ComfyExtension {
* Allows the extension to modify the graph data before it is configured.
* @param graphData The graph data
* @param missingNodeTypes The missing node types
* @param app The app instance
*/
beforeConfigureGraph?(
graphData: ComfyWorkflowJSON,
missingNodeTypes: MissingNodeType[]
missingNodeTypes: MissingNodeType[],
app: ComfyApp
): Promise<void> | void
/**
* Allows the extension to run code after the graph is configured.
* @param missingNodeTypes The missing node types
* @param app The app instance
*/
afterConfigureGraph?(
missingNodeTypes: MissingNodeType[]
missingNodeTypes: MissingNodeType[],
app: ComfyApp
): Promise<void> | void
/**

View File

@@ -211,7 +211,7 @@ export function createMockFileList(files: File[]): FileList {
* The ChangeTracker requires a proper ComfyWorkflowJSON structure
*/
export function createMockChangeTracker(
overrides: Record<string, unknown> = {}
overrides: Partial<ChangeTracker> = {}
): ChangeTracker {
const partial = {
activeState: {