diff --git a/browser_tests/tests/subgraph/subgraphPromotion.spec.ts b/browser_tests/tests/subgraph/subgraphPromotion.spec.ts index b7ebc8fea3d..74230749ba7 100644 --- a/browser_tests/tests/subgraph/subgraphPromotion.spec.ts +++ b/browser_tests/tests/subgraph/subgraphPromotion.spec.ts @@ -94,6 +94,55 @@ test.describe( 'Promoted Widget Visibility in Vue Mode', { tag: ['@vue-nodes'] }, () => { + test( + 'Promoted advanced widget remains visible when global advanced widgets are disabled', + { tag: ['@node'] }, + async ({ comfyPage }) => { + await comfyPage.settings.setSetting( + 'Comfy.Node.AlwaysShowAdvancedWidgets', + false + ) + const modelSamplingNode = await comfyPage.nodeOps.addNode( + 'ModelSamplingFlux', + {}, + { x: 500, y: 200 } + ) + await comfyPage.nextFrame() + await expect( + comfyPage.vueNodes.getNodeLocator(String(modelSamplingNode.id)) + ).toBeVisible() + + await modelSamplingNode.click('title') + const subgraphNode = await modelSamplingNode.convertToSubgraph() + const subgraphNodeId = String(subgraphNode.id) + + await comfyPage.vueNodes.enterSubgraph(subgraphNodeId) + const interiorNode = + comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux') + await expect(interiorNode).toBeVisible() + await interiorNode + .getByText('Show advanced inputs', { exact: true }) + .click() + await expect( + interiorNode.getByLabel('max_shift', { exact: true }) + ).toBeVisible() + await comfyPage.subgraph.promoteWidget(interiorNode, 'max_shift') + await comfyPage.subgraph.exitViaBreadcrumb() + + await expectPromotedWidgetNamesToContain( + comfyPage, + subgraphNodeId, + 'max_shift' + ) + + await expect( + comfyPage.vueNodes + .getNodeLocator(subgraphNodeId) + .getByLabel('max_shift', { exact: true }) + ).toBeVisible() + } + ) + test('Promoted text widget renders and enters the subgraph in Vue mode', async ({ comfyPage }) => { diff --git a/src/components/builder/AppModeWidgetList.test.ts b/src/components/builder/AppModeWidgetList.test.ts new file mode 100644 index 00000000000..2c9c15c895b --- /dev/null +++ b/src/components/builder/AppModeWidgetList.test.ts @@ -0,0 +1,154 @@ +import { createTestingPinia } from '@pinia/testing' +import { render, screen } from '@testing-library/vue' +import { fromAny } from '@total-typescript/shoehorn' +import { setActivePinia } from 'pinia' +import { createI18n } from 'vue-i18n' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import { toNodeId } from '@/types/nodeId' +import type { WidgetId } from '@/types/widgetId' + +import AppModeWidgetList from './AppModeWidgetList.vue' + +const mocks = vi.hoisted(() => ({ + extractVueNodeData: vi.fn(), + resolvedInputs: { value: [] as unknown[] } +})) + +vi.mock('@/components/builder/useResolvedSelectedInputs', () => ({ + useResolvedSelectedInputs: () => mocks.resolvedInputs +})) + +vi.mock('@/composables/graph/useGraphNodeManager', () => ({ + extractVueNodeData: mocks.extractVueNodeData +})) + +vi.mock('@/composables/maskeditor/useMaskEditor', () => ({ + useMaskEditor: () => ({ openMaskEditor: vi.fn() }) +})) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({ + canvas: { graph: { rootGraph: { id: 'graph-test' } } } + }) +})) + +vi.mock( + '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers', + () => ({ + useNodeEventHandlers: () => ({ handleNodeRightClick: vi.fn() }) + }) +) + +vi.mock('@/renderer/extensions/vueNodes/composables/useNodeTooltips', () => ({ + useNodeTooltips: () => ({ + createTooltipConfig: () => ({}), + getWidgetTooltip: () => '' + }) +})) + +vi.mock( + '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry', + () => ({ + getComponent: () => ({ + props: ['widget'], + template: '
' + }), + shouldExpand: () => false, + shouldRenderAsVue: () => true + }) +) + +vi.mock('@/scripts/app', () => ({ + app: { + isGraphReady: false, + rootGraph: { id: 'graph-test' } + } +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { remove: 'Remove', rename: 'Rename' } + } + } +}) + +describe('AppModeWidgetList', () => { + beforeEach(() => { + const widgetId = 'graph-test:1:max_shift' as WidgetId + const widget = fromAny({ + label: 'Max shift', + name: 'max_shift', + widgetId + }) + const node = fromAny({ + id: toNodeId(1), + mode: LGraphEventMode.ALWAYS, + title: 'Subgraph', + type: 'SubgraphNode' + }) + const nodeData: VueNodeData = { + executing: false, + id: toNodeId(1), + inputs: [], + mode: LGraphEventMode.ALWAYS, + outputs: [], + selected: false, + title: 'Subgraph', + type: 'SubgraphNode', + widgets: [ + { + name: 'max_shift', + options: { advanced: true }, + slotMetadata: { + index: 0, + linked: false, + promoted: true, + type: 'FLOAT' + }, + type: 'number', + widgetId + } + ] + } + + mocks.resolvedInputs.value = [ + { + displayName: 'max_shift', + node, + status: 'resolved', + widget, + widgetId + } + ] + mocks.extractVueNodeData.mockReturnValue(nodeData) + }) + + it('renders a selected promoted advanced widget', () => { + const pinia = createTestingPinia({ stubActions: false }) + setActivePinia(pinia) + + render(AppModeWidgetList, { + global: { + directives: { tooltip: { mounted: () => {} } }, + plugins: [pinia, i18n], + stubs: { + Button: { template: '' }, + DropZone: { template: '
' }, + InputSlot: true, + Popover: { template: '
' }, + WidgetDescription: true + } + } + }) + + expect(screen.getByTestId('advanced-widget-control')).toBeVisible() + }) +}) diff --git a/src/components/builder/AppModeWidgetList.vue b/src/components/builder/AppModeWidgetList.vue index 61de44ba4e0..914a519c142 100644 --- a/src/components/builder/AppModeWidgetList.vue +++ b/src/components/builder/AppModeWidgetList.vue @@ -77,8 +77,11 @@ const mappedSelections = computed((): WidgetEntry[] => { }) if (!matchingWidget) return [] - matchingWidget.slotMetadata = undefined - matchingWidget.nodeId = node.id + const projectedWidget = { + ...matchingWidget, + slotMetadata: undefined, + nodeId: node.id + } return [ { @@ -87,7 +90,8 @@ const mappedSelections = computed((): WidgetEntry[] => { description: config?.description, nodeData: { ...fullNodeData, - widgets: [matchingWidget] + showAdvanced: true, + widgets: [projectedWidget] }, action: { widget, node } } diff --git a/src/composables/graph/useGraphNodeManager.test.ts b/src/composables/graph/useGraphNodeManager.test.ts index 742f99f0f2e..d50f8a7bf64 100644 --- a/src/composables/graph/useGraphNodeManager.test.ts +++ b/src/composables/graph/useGraphNodeManager.test.ts @@ -112,7 +112,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => { return { graph, node, upstream, linkId: link.id } } - it('sets slotMetadata.linked to true when input has a link', () => { + it('identifies a linked regular widget input as unpromoted', () => { const { graph, node } = createWidgetInputGraph() const { vueNodeData } = useGraphNodeManager(graph) @@ -121,6 +121,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => { expect(widgetData?.slotMetadata).toBeDefined() expect(widgetData?.slotMetadata?.linked).toBe(true) + expect(widgetData?.slotMetadata?.promoted).toBe(false) }) it('updates slotMetadata.linked to false after link disconnect event', async () => { @@ -236,6 +237,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => { expect(widgetData).toBeDefined() expect(widgetData?.sourceWidgetName).toBe('prompt') expect(widgetData?.slotMetadata).toBeDefined() + expect(widgetData?.slotMetadata?.promoted).toBe(true) }) it('clears stale slotMetadata when input no longer matches widget', async () => { diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index 60e41bbed8e..d0ae72cbb82 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -48,6 +48,7 @@ export interface WidgetSlotMetadata { linked: boolean originNodeId?: NodeId originOutputName?: string + promoted: boolean type: string } @@ -352,6 +353,7 @@ function buildSlotMetadata( linked: input.link != null, originNodeId, originOutputName, + promoted: input.widgetId !== undefined, type: String(input.type) } if (input.name) metadata.set(input.name, slotInfo) diff --git a/src/lib/litegraph/src/interfaces.ts b/src/lib/litegraph/src/interfaces.ts index 54be0fc9b13..26e0fcd4193 100644 --- a/src/lib/litegraph/src/interfaces.ts +++ b/src/lib/litegraph/src/interfaces.ts @@ -365,6 +365,7 @@ export interface IWidgetLocator { export interface INodeInputSlot extends INodeSlot { link: LinkId | null widget?: IWidgetLocator + /** Host-owned promoted widget identity for a subgraph input (ADR 0009). */ widgetId?: WidgetId alwaysVisible?: boolean diff --git a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts index b0fd2733012..771d5cda45f 100644 --- a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts +++ b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts @@ -165,11 +165,11 @@ describe('isWidgetVisible', () => { expect(isWidgetVisible({ advanced: true }, true)).toBe(true) }) - it('keeps advanced widgets visible when linked and showAdvanced is false', () => { + it('keeps advanced widgets visible when advanced filtering is ignored', () => { expect(isWidgetVisible({ advanced: true }, false, true)).toBe(true) }) - it('keeps hidden widgets hidden when linked', () => { + it('keeps hidden widgets hidden when advanced filtering is ignored', () => { expect(isWidgetVisible({ hidden: true }, false, true)).toBe(false) }) }) @@ -408,6 +408,30 @@ describe('computeProcessedWidgets missing media', () => { }) }) +describe('computeProcessedWidgets visibility', () => { + it('keeps a promoted advanced widget visible without source metadata', () => { + const widget = createMockWidget({ + name: 'max_shift', + type: 'number', + options: { advanced: true }, + slotMetadata: { + index: 0, + linked: false, + promoted: true, + type: 'FLOAT' + }, + sourceExecutionId: undefined, + sourceWidgetName: undefined + }) + + const promotedWidget = processWidgets([widget])[0] + + expect(promotedWidget).toBeDefined() + expect(promotedWidget?.visible).toBe(true) + expect(promotedWidget?.simplified.borderStyle).toBeDefined() + }) +}) + describe('computeProcessedWidgets borderStyle', () => { beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) diff --git a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts index 664280d3cb2..5dd7ad6a9b3 100644 --- a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts +++ b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts @@ -213,11 +213,11 @@ function getWidgetNodeLocatorId( export function isWidgetVisible( options: IWidgetOptions, showAdvanced: boolean, - linked = false + ignoreAdvanced = false ): boolean { const hidden = options.hidden ?? false const advanced = options.advanced ?? false - return !hidden && (!advanced || showAdvanced || linked) + return !hidden && (!advanced || showAdvanced || ignoreAdvanced) } export function computeProcessedWidgets({ @@ -275,7 +275,7 @@ export function computeProcessedWidgets({ const visible = isWidgetVisible( mergedOptions, showAdvanced, - widget.slotMetadata?.linked + widget.slotMetadata?.linked || widget.slotMetadata?.promoted ) if (!identity.dedupeIdentity) { uniqueWidgets.push({