From 88874e0bd8f2c021a398eda42929c901db429893 Mon Sep 17 00:00:00 2001 From: jaeone94 <89377375+jaeone94@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:41:24 +0900 Subject: [PATCH 01/18] fix: hide local values for linked standard widgets --- .../widgets/linked_multiline_string.json | 72 +++++++ browser_tests/fixtures/selectors.ts | 2 + .../widgets/int/integerWidget.spec.ts | 47 +++-- .../text/multilineStringWidget.spec.ts | 49 +++++ src/locales/en/main.json | 1 + .../vueNodes/components/NodeWidgets.test.ts | 99 +++++++++- .../vueNodes/components/NodeWidgets.vue | 81 ++++++-- .../composables/useProcessedWidgets.test.ts | 177 +++++++++++++++++- .../composables/useProcessedWidgets.ts | 40 +++- .../composables/useWidgetRenderer.test.ts | 52 +++++ .../widgets/registry/widgetRegistry.ts | 20 ++ 11 files changed, 596 insertions(+), 44 deletions(-) create mode 100644 browser_tests/assets/widgets/linked_multiline_string.json diff --git a/browser_tests/assets/widgets/linked_multiline_string.json b/browser_tests/assets/widgets/linked_multiline_string.json new file mode 100644 index 00000000000..87573f64630 --- /dev/null +++ b/browser_tests/assets/widgets/linked_multiline_string.json @@ -0,0 +1,72 @@ +{ + "last_node_id": 2, + "last_link_id": 1, + "nodes": [ + { + "id": 1, + "type": "CLIPTextEncode", + "pos": [440, 80], + "size": [520, 280], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 1 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": ["stale local prompt"] + }, + { + "id": 2, + "type": "PrimitiveString", + "pos": [40, 120], + "size": [315, 58], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [1] + } + ], + "properties": { + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": ["authoritative upstream prompt"] + } + ], + "links": [[1, 2, 0, 1, 1, "STRING"]], + "groups": [], + "config": {}, + "extra": { + "ds": { + "offset": [0, 0], + "scale": 1 + } + }, + "version": 0.4 +} diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts index b4138bf547f..b6489597c3a 100644 --- a/browser_tests/fixtures/selectors.ts +++ b/browser_tests/fixtures/selectors.ts @@ -168,6 +168,8 @@ export const TestIds = { widgets: { container: 'node-widgets', widget: 'node-widget', + linkedContent: 'linked-widget-content', + linkedPlaceholder: 'linked-widget-placeholder', layoutFieldLabel: 'widget-layout-field-label', formDropdownMenu: 'form-dropdown-menu', decrement: 'decrement', diff --git a/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts b/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts index 985d9f17d05..46099ec7ad3 100644 --- a/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts +++ b/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts @@ -2,41 +2,56 @@ import { comfyExpect as expect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import { TestIds } from '@e2e/fixtures/selectors' test.describe('Vue Integer Widget', { tag: '@vue-nodes' }, () => { - test('should be disabled and not allow changing value when link connected to slot', async ({ + test('should hide a linked value and restore the control after disconnect', async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('vueNodes/linked-int-widget') - const seedWidget = comfyPage.vueNodes - .getWidgetByName('KSampler', 'seed') - .first() - const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget) - const initialValue = Number(await controls.input.inputValue()) - - // Verify widget is disabled when linked - await expect(controls.incrementButton).toBeDisabled() - await expect(controls.decrementButton).toBeDisabled() - await expect(controls.input).toHaveValue(initialValue.toString()) + const samplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler').first() + const placeholder = samplerNode.getByTestId( + TestIds.widgets.linkedPlaceholder + ) + const linkedContent = samplerNode.getByTestId(TestIds.widgets.linkedContent) + const nodeBounds = await samplerNode.boundingBox() + const [samplerNodeRef] = + await comfyPage.nodeOps.getNodeRefsByType('KSampler') + if (!samplerNodeRef || !nodeBounds) { + throw new Error('Linked KSampler node did not render') + } + const seedWidgetRef = await samplerNodeRef.getWidgetByName('seed') + const initialValue = Number(await seedWidgetRef.getValue()) - await expect(seedWidget).toBeVisible() + await expect(placeholder).toHaveAccessibleName('seed: Linked input') + await expect(linkedContent).toHaveAttribute('inert', '') + await expect( + samplerNode.getByRole('spinbutton', { name: 'seed' }) + ).toHaveCount(0) + await placeholder.click() + await expect.poll(() => seedWidgetRef.getValue()).toBe(initialValue) - // Delete the node that is linked to the slot (freeing up the widget) - // Click on the header to select the node (clicking center may land on - // the widget area where pointerdown.stop prevents node selection) await comfyPage.vueNodes .getNodeByTitle('Int') .locator('.lg-node-header') .click() await comfyPage.vueNodes.deleteSelected() - // Test widget works when unlinked + await expect(placeholder).toHaveCount(0) + const seedWidget = comfyPage.vueNodes + .getWidgetByName('KSampler', 'seed') + .first() + const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget) + await expect(controls.input).toHaveValue(initialValue.toString()) await controls.incrementButton.click() await expect(controls.input).toHaveValue((initialValue + 1).toString()) await controls.decrementButton.click() await expect(controls.input).toHaveValue(initialValue.toString()) + await expect + .poll(async () => (await samplerNode.boundingBox())?.height) + .toBe(nodeBounds.height) }) test('displays control widgets with default state', async ({ comfyPage }) => { diff --git a/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts b/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts index 61b9c9131e9..a2d32f508c2 100644 --- a/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts +++ b/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts @@ -3,6 +3,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' +import { TestIds } from '@e2e/fixtures/selectors' test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => { test.afterEach(async ({ comfyPage }) => { @@ -46,6 +47,54 @@ test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => { await expect(textarea).toHaveValue('Keep me around') }) + test('hides a linked core prompt without changing node geometry', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('widgets/linked_multiline_string') + + const clipNode = getFirstClipNode(comfyPage) + const placeholder = clipNode.getByTestId(TestIds.widgets.linkedPlaceholder) + const linkedContent = clipNode.getByTestId(TestIds.widgets.linkedContent) + const hiddenTextarea = clipNode.locator('textarea') + const nodeBounds = await clipNode.boundingBox() + + await expect(placeholder).toHaveAttribute( + 'data-linked-display', + 'expanding' + ) + await expect(placeholder).toHaveAccessibleName('text: Linked input') + await expect(linkedContent).toHaveAttribute('inert', '') + await expect(linkedContent).toHaveAttribute('aria-hidden', 'true') + await expect(clipNode.getByRole('textbox', { name: 'text' })).toHaveCount(0) + await expect(hiddenTextarea).toHaveValue('stale local prompt') + + await hiddenTextarea.evaluate((element) => element.focus()) + await expect + .poll(() => + hiddenTextarea.evaluate((element) => document.activeElement === element) + ) + .toBe(false) + + const [clipNodeRef] = + await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') + if (!clipNodeRef || !nodeBounds) { + throw new Error('Linked CLIPTextEncode node did not render') + } + const textInput = await clipNodeRef.getInput(1) + await textInput.removeLinks() + await comfyPage.nextFrame() + + await expect(placeholder).toHaveCount(0) + const restoredTextarea = getFirstMultilineStringWidget(comfyPage) + await expect(restoredTextarea).toBeVisible() + await expect(restoredTextarea).toHaveValue('stale local prompt') + await restoredTextarea.fill('restored local prompt') + await expect(restoredTextarea).toHaveValue('restored local prompt') + await expect + .poll(async () => (await clipNode.boundingBox())?.height) + .toBe(nodeBounds.height) + }) + test('should use native context menu when focused', async ({ comfyPage }) => { const textarea = getFirstMultilineStringWidget(comfyPage) const vueContextMenu = comfyPage.page.locator('.p-contextmenu') diff --git a/src/locales/en/main.json b/src/locales/en/main.json index b417a114d87..aea8f517219 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -3355,6 +3355,7 @@ "Set Group Nodes to Always": "Set Group Nodes to Always" }, "widgets": { + "linkedInput": "Linked input", "boolean": { "true": "true", "false": "false" diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts b/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts index 065bc66756b..da2ee578494 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts @@ -1,7 +1,7 @@ /* eslint-disable testing-library/no-container */ /* eslint-disable testing-library/no-node-access */ import { createTestingPinia } from '@pinia/testing' -import { render } from '@testing-library/vue' +import { render, screen, within } from '@testing-library/vue' import { setActivePinia } from 'pinia' import { nextTick } from 'vue' import { describe, expect, it, vi } from 'vitest' @@ -36,7 +36,7 @@ const WidgetStub = { name: 'WidgetStub', props: ['widget', 'nodeId', 'nodeType', 'modelValue'], template: - '
' + '' } vi.mock( @@ -352,4 +352,99 @@ describe('NodeWidgets', () => { expect(ids).toStrictEqual([seedAEntityId, seedBEntityId]) }) + + it('keeps linked content mounted but inert and hidden from accessibility', () => { + const linkedWidgetId = widgetId(GRAPH_ID, toNodeId('test_node'), 'prompt') + const nodeData = createMockNodeData('TestNode', [ + createMockWidget({ + widgetId: linkedWidgetId, + name: 'prompt', + type: 'text', + slotMetadata: { + index: 0, + linked: true, + type: 'STRING' + } + }) + ]) + + const { container } = renderComponent(nodeData, () => { + useWidgetValueStore().registerWidget(linkedWidgetId, { + type: 'text', + value: 'stale local prompt', + options: {} + }) + }) + + const linkedContent = screen.getByTestId('linked-widget-content') + expect(linkedContent).toHaveAttribute('inert') + expect(linkedContent).toHaveAttribute('aria-hidden', 'true') + expect(container.querySelector('.widget-stub')).not.toBeNull() + expect(within(linkedContent).queryByRole('button')).toBeNull() + expect( + screen.getByRole('img', { name: 'prompt: Linked input' }) + ).toBeVisible() + expect( + screen.getByTestId('linked-widget-placeholder') + ).not.toHaveTextContent('stale local prompt') + }) + + it('places indicators on unlabeled switches and full labeled controls', () => { + const labeledWidgetId = widgetId(GRAPH_ID, toNodeId('test_node'), 'labeled') + const nodeData = createMockNodeData('TestNode', [ + createMockWidget({ + name: 'unlabeled', + type: 'boolean', + slotMetadata: { + index: 0, + linked: true, + type: 'BOOLEAN' + } + }), + createMockWidget({ + widgetId: labeledWidgetId, + name: 'labeled', + type: 'toggle', + slotMetadata: { + index: 1, + linked: true, + type: 'BOOLEAN' + } + }) + ]) + + renderComponent(nodeData, () => { + useWidgetValueStore().registerWidget(labeledWidgetId, { + type: 'toggle', + value: false, + options: { off: 'No', on: 'Yes' } + }) + }) + + const placeholders = screen.getAllByTestId('linked-widget-placeholder') + expect(placeholders[0]).toHaveAttribute('data-linked-display', 'switch') + expect(placeholders[1]).toHaveAttribute('data-linked-display', 'control') + }) + + it('keeps the expanding textarea component mounted while linked', () => { + const nodeData = createMockNodeData('CLIPTextEncode', [ + createMockWidget({ + name: 'text', + type: 'customtext', + slotMetadata: { + index: 1, + linked: true, + type: 'STRING' + } + }) + ]) + + const { container } = renderComponent(nodeData) + + expect(container.querySelector('.widget-stub')).not.toBeNull() + expect(screen.getByTestId('linked-widget-placeholder')).toHaveAttribute( + 'data-linked-display', + 'expanding' + ) + }) }) diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue index 7c190408fa2..c9ffd6ab011 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue @@ -53,28 +53,69 @@ /> -