Skip to content

Commit f5aeb84

Browse files
comfy-pr-botDrJKLclaudeGlary-Bot
authored
[backport core/1.48] fix: preserve subgraph node size when editing or promoting widgets (FE-853) (#14491)
Backport of #14461 to `core/1.48` Automatically created by backport workflow. Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
1 parent e731455 commit f5aeb84

10 files changed

Lines changed: 536 additions & 11 deletions

File tree

browser_tests/fixtures/components/ContextMenu.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ export class ContextMenu {
6161
return this
6262
}
6363

64+
async openForDisabledElement(locator: Locator): Promise<this> {
65+
await locator.dispatchEvent('contextmenu', { button: 2 })
66+
await expect(this.anyMenu).toBeVisible()
67+
return this
68+
}
69+
6470
/**
6571
* Select a Vue node by clicking its header, then right-click to open
6672
* the context menu. Vue nodes require a selection click before the

browser_tests/fixtures/components/SubgraphEditor.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,23 @@ import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
99
export class SubgraphEditor {
1010
public readonly root: Locator
1111
public readonly promotionItems: Locator
12+
public readonly selectionToolboxButton: Locator
1213

1314
constructor(protected readonly comfyPage: ComfyPage) {
1415
this.root = this.comfyPage.menu.propertiesPanel.root
1516
this.promotionItems = this.root.getByTestId(
1617
TestIds.subgraphEditor.widgetItem
1718
)
19+
this.selectionToolboxButton = this.comfyPage.selectionToolbox.getByRole(
20+
'button',
21+
{ name: 'Edit Subgraph Widgets' }
22+
)
23+
}
24+
25+
async openFromSelectionToolbox(subgraphNode: Locator) {
26+
await new VueNodeFixture(subgraphNode).select()
27+
await this.selectionToolboxButton.click()
28+
await expect(this.root, 'Open Properties Panel').toBeVisible()
1829
}
1930

2031
async ensureOpen(subgraphNode: Locator) {

browser_tests/fixtures/helpers/NodeOperationsHelper.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
1212
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
1313
import type { Position, Size } from '@e2e/fixtures/types'
1414
import { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
15+
import type { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
1516

1617
export class NodeOperationsHelper {
1718
public readonly promptDialogInput: Locator
@@ -216,6 +217,36 @@ export class NodeOperationsHelper {
216217
}
217218
}
218219

220+
/**
221+
* Enlarges the node titled `title` by dragging its bottom-right Vue resize
222+
* handle. The returned size is read from the graph model, not a DOM bounding
223+
* box, so it stays comparable across zoom and side-panel layout changes.
224+
*/
225+
async growNodeByDrag(
226+
title: string,
227+
delta: { x: number; y: number }
228+
): Promise<{ nodeRef: NodeReference; node: VueNodeFixture; size: Size }> {
229+
const [nodeRef] = await this.getNodeRefsByTitle(title)
230+
if (!nodeRef) throw new Error(`No node titled "${title}" on the canvas`)
231+
232+
// Saved pans can leave the node too low for a downward drag to stay onscreen.
233+
await nodeRef.centerOnNode()
234+
235+
const node = await this.comfyPage.vueNodes.getFixtureByTitle(title)
236+
const sizeBefore = await nodeRef.getSize()
237+
await node.resizeFromCorner('SE', delta.x, delta.y)
238+
await this.comfyPage.nextFrame()
239+
240+
const size = await nodeRef.getSize()
241+
if (size.width <= sizeBefore.width || size.height <= sizeBefore.height) {
242+
throw new Error(
243+
`Resize drag did not enlarge "${title}": ${sizeBefore.width}x${sizeBefore.height} -> ${size.width}x${size.height}`
244+
)
245+
}
246+
247+
return { nodeRef, node, size }
248+
}
249+
219250
async fillPromptDialog(value: string): Promise<void> {
220251
await this.promptDialogInput.fill(value)
221252
await this.page.keyboard.press('Enter')

browser_tests/fixtures/helpers/SubgraphHelper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ export class SubgraphHelper {
360360
): Promise<void> {
361361
const widget = nodeLocator.getByLabel(widgetName, { exact: true })
362362
await this.comfyPage.contextMenu
363-
.openFor(widget)
363+
.openForDisabledElement(widget)
364364
.then((m) => m.clickMenuItemExact(`Un-Promote Widget: ${widgetName}`))
365365
}
366366

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {
2+
comfyExpect as expect,
3+
comfyPageFixture as test
4+
} from '@e2e/fixtures/ComfyPage'
5+
6+
test.describe(
7+
'Subgraph node size across widget editing',
8+
{
9+
tag: ['@subgraph', '@node', '@widget', '@vue-nodes'],
10+
annotation: {
11+
type: 'issue',
12+
description:
13+
'FE-853: widget editing collapsed a user-resized subgraph node to its minimum size'
14+
}
15+
},
16+
() => {
17+
test.beforeEach(async ({ comfyPage }) => {
18+
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
19+
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
20+
})
21+
22+
test('retains a manual resize when Edit Subgraph Widgets is clicked', async ({
23+
comfyPage
24+
}) => {
25+
const { nodeRef, node, size } = await comfyPage.nodeOps.growNodeByDrag(
26+
'New Subgraph',
27+
{ x: 250, y: 250 }
28+
)
29+
30+
await comfyPage.subgraph.editor.openFromSelectionToolbox(node.root)
31+
await comfyPage.nextFrame()
32+
33+
const sizeAfter = await nodeRef.getSize()
34+
expect(sizeAfter.width).toBeCloseTo(size.width, 0)
35+
expect(sizeAfter.height).toBeCloseTo(size.height, 0)
36+
})
37+
38+
test('retains a manual resize when an interior widget is promoted', async ({
39+
comfyPage
40+
}) => {
41+
const { nodeRef, size } = await comfyPage.nodeOps.growNodeByDrag(
42+
'New Subgraph',
43+
{ x: 250, y: 250 }
44+
)
45+
46+
await comfyPage.vueNodes.enterSubgraph(String(nodeRef.id))
47+
await comfyPage.subgraph.promoteWidget(
48+
comfyPage.vueNodes.getNodeByTitle('KSampler'),
49+
'steps'
50+
)
51+
await comfyPage.subgraph.exitViaBreadcrumb()
52+
53+
// Guards against a vacuous pass: if promotion silently became a no-op,
54+
// the size assertions below would hold without exercising the bug.
55+
await expect
56+
.poll(() =>
57+
comfyPage.subgraph.getPromotedWidgetOrder(String(nodeRef.id))
58+
)
59+
.toContain('steps')
60+
61+
const sizeAfter = await nodeRef.getSize()
62+
expect(sizeAfter.width).toBeCloseTo(size.width, 0)
63+
// A newly promoted widget can legitimately raise the minimum height, so
64+
// only a shrink below the user's size is a regression.
65+
expect(sizeAfter.height).toBeGreaterThanOrEqual(size.height)
66+
})
67+
}
68+
)
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { expect } from '@playwright/test'
2+
3+
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
4+
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
5+
6+
/**
7+
* Regression coverage for FE-853: promoting/demoting a widget on a subgraph
8+
* node — or merely opening the right-side properties panel for one — used to
9+
* collapse the node back to its computed minimum size, discarding whatever
10+
* size the user had explicitly set.
11+
*/
12+
13+
/**
14+
* These fixture workflows carry a saved pan/zoom that was not authored
15+
* against the default 1280x720 Playwright viewport, so nodes can load
16+
* partially below the fold. Panning the nodes into view keeps resize
17+
* handles and the subgraph-enter footer button on-screen for subsequent
18+
* screen-space clicks and drags.
19+
*
20+
* This deliberately pans rather than using the app's zoom-to-fit command:
21+
* these tests compute drag targets and pixel-space size deltas directly
22+
* from node.pos/node.size, which only line up with on-screen pixels when
23+
* the canvas scale is 1. Panning leaves scale untouched, so it also avoids
24+
* the 'Top' menu bar's workflow tab strip, which renders above the canvas
25+
* and would intercept a click at a fixed coordinate.
26+
*/
27+
async function fitViewToNodes(comfyPage: ComfyPage): Promise<void> {
28+
await comfyPage.page.evaluate(() => {
29+
const canvas = window.app!.canvas
30+
const nodes = canvas.graph?.nodes ?? []
31+
if (nodes.length === 0) return
32+
33+
const margin = 100
34+
const left = Math.min(...nodes.map((node) => node.pos[0]))
35+
const top = Math.min(...nodes.map((node) => node.pos[1]))
36+
37+
canvas.ds.scale = 1
38+
canvas.ds.offset = [margin - left, margin - top]
39+
canvas.setDirty(true, true)
40+
})
41+
await comfyPage.nextFrame()
42+
}
43+
44+
test.describe(
45+
'Subgraph node resize preservation',
46+
{ tag: ['@subgraph', '@widget', '@vue-nodes'] },
47+
() => {
48+
test.beforeEach(async ({ comfyPage }) => {
49+
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
50+
})
51+
52+
test('Promoting a widget preserves a user-resized subgraph node', async ({
53+
comfyPage
54+
}) => {
55+
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
56+
await fitViewToNodes(comfyPage)
57+
58+
const subgraphNode =
59+
await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
60+
await subgraphNode.resizeFromCorner('SE', 250, 200)
61+
await comfyPage.nextFrame()
62+
const resizedBox = (await subgraphNode.boundingBox())!
63+
64+
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
65+
await comfyPage.vueNodes.enterSubgraph('2')
66+
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
67+
await comfyPage.subgraph.exitViaBreadcrumb()
68+
69+
const box = await subgraphNode.boundingBox()
70+
expect(box?.width).toBeCloseTo(resizedBox.width, 0)
71+
expect(box?.height).toBeGreaterThanOrEqual(resizedBox.height - 1)
72+
})
73+
74+
test('Un-promoting a widget preserves a user-resized subgraph node', async ({
75+
comfyPage
76+
}) => {
77+
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
78+
await fitViewToNodes(comfyPage)
79+
80+
const subgraphNode =
81+
await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
82+
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
83+
await comfyPage.vueNodes.enterSubgraph('2')
84+
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
85+
await comfyPage.subgraph.exitViaBreadcrumb()
86+
87+
await subgraphNode.resizeFromCorner('SE', 250, 200)
88+
await comfyPage.nextFrame()
89+
const resizedBox = (await subgraphNode.boundingBox())!
90+
91+
await comfyPage.vueNodes.enterSubgraph('2')
92+
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
93+
await comfyPage.subgraph.exitViaBreadcrumb()
94+
95+
const box = await subgraphNode.boundingBox()
96+
expect(box?.width).toBeCloseTo(resizedBox.width, 0)
97+
expect(box?.height).toBeCloseTo(resizedBox.height, 0)
98+
})
99+
100+
test('Opening the properties panel does not shrink a user-resized subgraph node', async ({
101+
comfyPage
102+
}) => {
103+
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
104+
await fitViewToNodes(comfyPage)
105+
106+
const subgraphNode =
107+
await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
108+
await subgraphNode.resizeFromCorner('SE', 250, 200)
109+
await comfyPage.nextFrame()
110+
const resizedBox = (await subgraphNode.boundingBox())!
111+
112+
await subgraphNode.select()
113+
await comfyPage.actionbar.propertiesButton.click()
114+
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
115+
116+
const box = await subgraphNode.boundingBox()
117+
expect(box?.width).toBeCloseTo(resizedBox.width, 0)
118+
expect(box?.height).toBeCloseTo(resizedBox.height, 0)
119+
})
120+
121+
test('Promoting and demoting multiple widgets in sequence preserves the resized size', async ({
122+
comfyPage
123+
}) => {
124+
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
125+
await fitViewToNodes(comfyPage)
126+
127+
const subgraphNode =
128+
await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
129+
await subgraphNode.resizeFromCorner('SE', 300, 250)
130+
await comfyPage.nextFrame()
131+
const resizedBox = (await subgraphNode.boundingBox())!
132+
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
133+
134+
await comfyPage.vueNodes.enterSubgraph('2')
135+
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
136+
await comfyPage.subgraph.promoteWidget(ksampler, 'cfg')
137+
await comfyPage.subgraph.exitViaBreadcrumb()
138+
139+
await expect
140+
.poll(async () => (await subgraphNode.boundingBox())?.width)
141+
.toBeCloseTo(resizedBox.width, 0)
142+
143+
await comfyPage.vueNodes.enterSubgraph('2')
144+
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
145+
await comfyPage.subgraph.exitViaBreadcrumb()
146+
147+
await comfyPage.vueNodes.enterSubgraph('2')
148+
await comfyPage.subgraph.unpromoteWidget(ksampler, 'cfg')
149+
await comfyPage.subgraph.exitViaBreadcrumb()
150+
151+
const box = await subgraphNode.boundingBox()
152+
expect(box?.width).toBeCloseTo(resizedBox.width, 0)
153+
expect(box?.height).toBeCloseTo(resizedBox.height, 0)
154+
})
155+
156+
test('Manually resizing still works normally after a promotion', async ({
157+
comfyPage
158+
}) => {
159+
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
160+
await fitViewToNodes(comfyPage)
161+
162+
const subgraphNode =
163+
await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
164+
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
165+
await comfyPage.vueNodes.enterSubgraph('2')
166+
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
167+
await comfyPage.subgraph.exitViaBreadcrumb()
168+
169+
const boxBeforeResize = (await subgraphNode.boundingBox())!
170+
await subgraphNode.resizeFromCorner('SE', 200, 150)
171+
await comfyPage.nextFrame()
172+
173+
const box = await subgraphNode.boundingBox()
174+
expect(box?.width).toBeGreaterThan(boxBeforeResize.width)
175+
expect(box?.height).toBeGreaterThan(boxBeforeResize.height)
176+
})
177+
}
178+
)
179+
180+
test.describe(
181+
'Subgraph node resize preservation — nested subgraphs',
182+
{ tag: ['@subgraph', '@widget', '@vue-nodes'] },
183+
() => {
184+
test.beforeEach(async ({ comfyPage }) => {
185+
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
186+
})
187+
188+
test('Demoting a nested promotion does not shrink a user-resized outer host', async ({
189+
comfyPage
190+
}) => {
191+
await comfyPage.workflow.loadWorkflow(
192+
'subgraphs/subgraph-nested-promotion'
193+
)
194+
await fitViewToNodes(comfyPage)
195+
196+
const [outerHost] = await comfyPage.nodeOps.getNodeRefsByTitle('Sub 0')
197+
expect(outerHost).toBeDefined()
198+
199+
const nodePos = await outerHost.getPosition()
200+
const nodeSize = await outerHost.getSize()
201+
// Keep the ratio modest: resizeNode scales the existing size (not an
202+
// additive delta), and a larger ratio would push the resized node's
203+
// bottom edge — and the subgraph-enter footer button below it — off
204+
// the bottom of the viewport, since the top-left corner stays fixed
205+
// during a resize-from-corner drag.
206+
await comfyPage.nodeOps.resizeNode(nodePos, nodeSize, 1.4, 1.4)
207+
const resizedSize = await outerHost.getSize()
208+
expect(resizedSize.width).toBeGreaterThan(nodeSize.width)
209+
expect(resizedSize.height).toBeGreaterThan(nodeSize.height)
210+
211+
// Node 6 ('Sub 1' instance) exposes a 'value_1' widget that is itself a
212+
// promotion chain three subgraphs deep (Inner 3 -> Sub 2 -> Sub 1),
213+
// which Sub 0 re-promotes onto `outerHost`. The per-row toggle in the
214+
// properties panel is disabled for widgets promoted this way, so
215+
// demoting has to go through the same context-menu action a user would
216+
// use: entering the host and un-promoting from the widget's own node.
217+
await comfyPage.vueNodes.enterSubgraph(String(outerHost.id))
218+
const sub1Instance = comfyPage.vueNodes.getNodeLocator('6')
219+
await comfyPage.subgraph.unpromoteWidget(sub1Instance, 'value_1')
220+
await comfyPage.subgraph.exitViaBreadcrumb()
221+
222+
await expect
223+
.poll(async () => (await outerHost.getSize()).width)
224+
.toBeCloseTo(resizedSize.width, 0)
225+
const finalSize = await outerHost.getSize()
226+
expect(finalSize.height).toBeCloseTo(resizedSize.height, 0)
227+
})
228+
}
229+
)

0 commit comments

Comments
 (0)