Compare commits

...

5 Commits

Author SHA1 Message Date
Alexander Brown
68fdfd5e35 Merge branch 'main' into glary/widget-control-mode-e2e-tests 2026-05-20 12:58:27 -07:00
DrJKL
5841c252ce Merge remote-tracking branch 'origin/main' into glary/widget-control-mode-e2e-tests
# Conflicts:
#	browser_tests/tests/subgraph/subgraphPromotion.spec.ts
#	tools/devtools/nodes/inputs.py
2026-05-19 18:40:12 -07:00
Glary-Bot
4d4ad6ed92 refactor: move subgraph control widget helper to SubgraphHelper fixture 2026-04-20 00:23:15 +00:00
Glary-Bot
86b6cab5e9 fix: address CodeRabbit review - node size floor, vacuous every() guard 2026-04-19 08:19:11 +00:00
Glary-Bot
0aefef7c42 test: add e2e coverage for Comfy.WidgetControlMode setting watcher
Add new numberControlWidget.spec.ts with tests covering GraphCanvas.vue
lines 355-366 (0% coverage). Tests verify control widget labels update
when toggling between 'before' and 'after' modes, including multi-node
traversal, widgetless node handling, canvas dirty marking, linkedWidgets
label updates, and subgraph node traversal.

- Add DevToolsNodeWithComboControlWidget for combo+filter list testing
- Move Number widget tests from widget.spec.ts to new file
- Add subgraph WidgetControlMode test to subgraphPromotion.spec.ts
2026-04-19 08:09:15 +00:00
8 changed files with 360 additions and 22 deletions

View File

@@ -0,0 +1,31 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "DevToolsNodeWithComboControlWidget",
"pos": [20, 50],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "DevToolsNodeWithComboControlWidget"
},
"widgets_values": ["Option A", "fixed", ""]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -498,6 +498,25 @@ export class SubgraphHelper {
await this.comfyPage.contextMenu.waitForHidden()
}
async getInnerControlWidgetLabels(): Promise<string[]> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n: { isSubgraphNode?: () => boolean }) =>
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
) as { subgraph?: Subgraph } | undefined
if (!subgraphNode?.subgraph) return []
const innerNodes = Array.from(subgraphNode.subgraph.nodes.values())
return innerNodes.flatMap((n: { widgets?: Array<{ label?: string }> }) =>
(n.widgets ?? [])
.filter((w: { label?: string }) =>
(w.label ?? '').includes('control')
)
.map((w: { label?: string }) => w.label!)
)
})
}
async findSubgraphNodeId(): Promise<string> {
const id = await this.page.evaluate(() => {
const graph = window.app!.canvas.graph!

View File

@@ -0,0 +1,251 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
test('Can drag adjust value', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/seed_widget')
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const widget = await node.getWidget(0)
await comfyPage.page.evaluate(() => {
window.widgetValue = undefined
const widget = window.app!.graph!.nodes[0].widgets![0]
widget.callback = (value: number) => {
window.widgetValue = value
}
})
await widget.dragHorizontal(50)
await expect(comfyPage.canvas).toHaveScreenshot('seed_widget_dragged.png')
await expect
.poll(() => comfyPage.page.evaluate(() => window.widgetValue))
.toBeDefined()
})
})
test.describe('WidgetControlMode setting', { tag: '@widget' }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
})
test('Changing mode to "before" updates control widget labels', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
const ksampler = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
await expect
.poll(() =>
comfyPage.page.evaluate((id) => {
const node = window.app!.graph!.getNodeById(id)
return node?.widgets
?.filter((w) => (w.label ?? '').includes('control'))
.map((w) => w.label)
}, ksampler.id)
)
.toEqual(expect.arrayContaining([expect.stringContaining('after')]))
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
await expect
.poll(() =>
comfyPage.page.evaluate((id) => {
const node = window.app!.graph!.getNodeById(id)
return node?.widgets
?.filter((w) => (w.label ?? '').includes('control'))
.map((w) => w.label)
}, ksampler.id)
)
.toEqual(expect.arrayContaining([expect.stringContaining('before')]))
})
test('Changing mode back to "after" restores labels', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
const ksampler = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
await expect
.poll(() =>
comfyPage.page.evaluate((id) => {
const node = window.app!.graph!.getNodeById(id)
return node?.widgets
?.filter((w) => (w.label ?? '').includes('control'))
.map((w) => w.label)
}, ksampler.id)
)
.toEqual(expect.arrayContaining([expect.stringContaining('after')]))
})
test('Mode change updates control widgets across multiple nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('KSampler')
node!.pos = [400, 30]
window.app!.graph!.add(node!)
})
await comfyPage.nextFrame()
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const ksamplers = window.app!.graph!.nodes.filter(
(n) => n.type === 'KSampler'
)
return (
ksamplers.length === 2 &&
ksamplers.every((n) => {
const controlLabels = (n.widgets ?? [])
.filter((w) => (w.label ?? '').includes('control'))
.map((w) => w.label ?? '')
return (
controlLabels.length > 0 &&
controlLabels.every((label) => label.includes('before'))
)
})
)
})
)
.toBe(true)
})
test('Nodes without widgets are skipped without error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('Reroute')
if (node) {
node.pos = [400, 30]
window.app!.graph!.add(node)
}
})
await comfyPage.nextFrame()
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
const ksampler = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
await expect
.poll(() =>
comfyPage.page.evaluate((id) => {
const node = window.app!.graph!.getNodeById(id)
return node?.widgets
?.filter((w) => (w.label ?? '').includes('control'))
.map((w) => w.label)
}, ksampler.id)
)
.toEqual(expect.arrayContaining([expect.stringContaining('before')]))
})
test('Canvas is marked dirty after mode change', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.page.evaluate(() => {
const w = window as Window & { __canvasDirtied?: boolean }
w.__canvasDirtied = false
const origSetDirty = window.app!.canvas.setDirty.bind(window.app!.canvas)
window.app!.canvas.setDirty = (
...args: Parameters<typeof origSetDirty>
) => {
w.__canvasDirtied = true
return origSetDirty(...args)
}
})
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
await expect
.poll(() =>
comfyPage.page.evaluate(
() =>
(window as Window & { __canvasDirtied?: boolean }).__canvasDirtied
)
)
.toBe(true)
})
test('Mode change updates combo control widget labels', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
await comfyPage.workflow.loadWorkflow('widgets/combo_control_widget')
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph!.nodes[0]
return (node?.widgets ?? [])
.filter((w) => (w.label ?? '').includes('control'))
.map((w) => w.label!)
})
)
.toEqual(expect.arrayContaining([expect.stringContaining('after')]))
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph!.nodes[0]
return (node?.widgets ?? [])
.filter((w) => (w.label ?? '').includes('control'))
.map((w) => w.label!)
})
)
.toEqual(expect.arrayContaining([expect.stringContaining('before')]))
})
test('Mode change propagates to linkedWidgets on control widgets', async ({
comfyPage
}) => {
// linkedWidgets is only set on main widgets, never on control widgets
// themselves. This covers the defensive code path (GraphCanvas.vue:360-362).
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.page.evaluate(() => {
const node = window.app!.graph!.nodes[0]
if (!node?.widgets) return
const controlWidget = node.widgets.find((w) =>
(w.label ?? '').includes('control')
)
if (!controlWidget) return
const mockLinked = Object.create(null)
mockLinked.name = 'mock_filter'
mockLinked.label = 'control after generate'
mockLinked.type = 'string'
mockLinked.value = ''
controlWidget.linkedWidgets = [mockLinked]
})
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph!.nodes[0]
const controlWidget = node?.widgets?.find((w) =>
(w.label ?? '').includes('control')
)
const linked = controlWidget?.linkedWidgets ?? []
return [controlWidget?.label, ...linked.map((l) => l.label ?? '')]
})
)
.toEqual(
expect.arrayContaining([
expect.stringContaining('before'),
expect.stringContaining('before')
])
)
})
})

View File

@@ -608,6 +608,33 @@ test.describe(
}
)
test.describe(
'WidgetControlMode in subgraphs',
{ tag: ['@subgraph', '@widget'] },
() => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
})
test('Mode change updates control widget labels inside subgraph nodes', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await expect
.poll(() => comfyPage.subgraph.getInnerControlWidgetLabels())
.toEqual(expect.arrayContaining([expect.stringContaining('after')]))
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
await expect
.poll(() => comfyPage.subgraph.getInnerControlWidgetLabels())
.toEqual(expect.arrayContaining([expect.stringContaining('before')]))
})
}
)
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const ksampler = comfyPage.vueNodes.getNodeLocator('1')

View File

@@ -137,28 +137,6 @@ test.describe('Slider widget', { tag: ['@screenshot', '@widget'] }, () => {
})
})
test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
test('Can drag adjust value', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/seed_widget')
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const widget = await node.getWidget(0)
await comfyPage.page.evaluate(() => {
window.widgetValue = undefined
const widget = window.app!.graph!.nodes[0].widgets![0]
widget.callback = (value: number) => {
window.widgetValue = value
}
})
await widget.dragHorizontal(50)
await expect(comfyPage.canvas).toHaveScreenshot('seed_widget_dragged.png')
await expect
.poll(() => comfyPage.page.evaluate(() => window.widgetValue))
.toBeDefined()
})
})
test.describe(
'Dynamic widget manipulation',
{ tag: ['@screenshot', '@widget'] },

View File

@@ -10,6 +10,7 @@ from .nodes import (
LongComboDropdown,
MultiSelectNode,
NodeWithBooleanInput,
NodeWithComboControlWidget,
NodeWithDefaultInput,
NodeWithForceInput,
NodeWithOptionalComboInput,
@@ -43,6 +44,7 @@ __all__ = [
"LongComboDropdown",
"MultiSelectNode",
"NodeWithBooleanInput",
"NodeWithComboControlWidget",
"NodeWithDefaultInput",
"NodeWithForceInput",
"NodeWithOptionalComboInput",

View File

@@ -11,6 +11,7 @@ from .errors import (
from .inputs import (
LongComboDropdown,
NodeWithBooleanInput,
NodeWithComboControlWidget,
NodeWithDefaultInput,
NodeWithForceInput,
NodeWithOptionalComboInput,
@@ -69,6 +70,7 @@ __all__ = [
"LongComboDropdown",
"MultiSelectNode",
"NodeWithBooleanInput",
"NodeWithComboControlWidget",
"NodeWithDefaultInput",
"NodeWithForceInput",
"NodeWithOptionalComboInput",

View File

@@ -344,6 +344,31 @@ class NodeWithPriceBadge(IO.ComfyNode):
return IO.NodeOutput()
class NodeWithComboControlWidget:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"combo_option": (
"COMBO",
{
"options": ["Option A", "Option B", "Option C"],
"control_after_generate": True,
},
),
},
}
RETURN_TYPES = ("STRING",)
FUNCTION = "execute"
CATEGORY = "DevTools"
DESCRIPTION = "A node with a combo input that has control_after_generate, producing control widgets with a filter list"
OUTPUT_NODE = True
def execute(self, combo_option: str):
return (combo_option,)
NODE_CLASS_MAPPINGS = {
"DevToolsLongComboDropdown": LongComboDropdown,
"DevToolsNodeWithOptionalInput": NodeWithOptionalInput,
@@ -359,6 +384,7 @@ NODE_CLASS_MAPPINGS = {
"DevToolsNodeWithSeedInput": NodeWithSeedInput,
"DevToolsNodeWithValidation": NodeWithValidation,
"DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput,
"DevToolsNodeWithComboControlWidget": NodeWithComboControlWidget,
"DevToolsNodeWithLegacyWidget": NodeWithLegacyWidget,
"DevToolsNodeWithPriceBadge": NodeWithPriceBadge,
}
@@ -378,6 +404,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"DevToolsNodeWithSeedInput": "Node With Seed Input",
"DevToolsNodeWithValidation": "Node With Validation",
"DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input",
"DevToolsNodeWithComboControlWidget": "Node With Combo Control Widget",
"DevToolsNodeWithLegacyWidget": "Node With Legacy Widget",
"DevToolsNodeWithPriceBadge": "Node With Price Badge",
}
@@ -397,6 +424,7 @@ __all__ = [
"NodeWithSeedInput",
"NodeWithValidation",
"NodeWithV2ComboInput",
"NodeWithComboControlWidget",
"NODE_CLASS_MAPPINGS",
"NODE_DISPLAY_NAME_MAPPINGS",
]