Files
ComfyUI_frontend/browser_tests/tests/subgraph.spec.ts
AustinMroz 3c8b7b015c Fix subgraphNode widget cloning with compressed target_slot (#7388)
## Cause
When graphs are actually exported, several layers of cleanup are
applied. Among these is link compression. Any widgets with inputs that
aren't used do not have inputs stored in the workflow. This was
implemented for backwards compatibility with the old "convert to input"
system for widgets. As part of this process, the target_slots on links
are rewritten such that they point to the index of the widget as if
unconnected widgets did not exist.

This "incorrect" state for links is only corrected AFTER a workflow has
loaded because the 'fix' method needs nodes to be initialized in order
to calculate the correct target_slot

This becomes a problem when subgraphs are introduced. SubgraphInputs
need to resolve a link to its target slot in order to construct a clone
of the linked widget DURING the loading process. Since this target slot
is not accurate, this can result in the cloned widget having the wrong
type.

For a minimal reproduction:
- Create a subgraph with an Empty Latent Image with batch_size linked to
the Subgraph Input
- Export the workflow
- On load, the batch_size has step and min attributes which incorrectly
correspond to width

## Fix
There's multiple possible ways to address this and input on direction is
appreciated
- Fix links before loading graph
  - Likely to break with any dynamic state
- Fix links, then load graph again
- Ugly, bad performance, dynamic state may require multiple passes to
correctly ripple
- In the Subgraph code, ignore target_slot and instead `.find()` input
with matching linkId (proposed)
- Promising, but means accepting that state is just wrong sometimes.
Another forever footgun.
- Entirely remove the input compression
- Some people may complain, and old workflows still need to be supported
- Only remove target_slot redirection inside subgraphs
- Creates ugly logical difference between what happens inside and
outside subgraphs.
  - Still leaves old workflows broken

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7388-Remove-target_slot-compression-from-subgraph-exports-2c66d73d3650815d8c96c5047958ab67)
by [Unito](https://www.unito.io)
2025-12-11 12:32:35 -08:00

820 lines
27 KiB
TypeScript

import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
const TEST_WIDGET_CONTENT = 'Test content that should persist'
// Common selectors
const SELECTORS = {
breadcrumb: '.subgraph-breadcrumb',
promptDialog: '.graphdialog input',
nodeSearchContainer: '.node-search-container',
domWidget: '.comfy-multiline-input'
} as const
test.describe('Subgraph Operations', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
// Helper to get subgraph slot count
async function getSubgraphSlotCount(
comfyPage: typeof test.prototype.comfyPage,
type: 'inputs' | 'outputs'
): Promise<number> {
return await comfyPage.page.evaluate((slotType) => {
return window['app'].canvas.graph[slotType]?.length || 0
}, type)
}
// Helper to get current graph node count
async function getGraphNodeCount(
comfyPage: typeof test.prototype.comfyPage
): Promise<number> {
return await comfyPage.page.evaluate(() => {
return window['app'].canvas.graph.nodes?.length || 0
})
}
// Helper to verify we're in a subgraph
async function isInSubgraph(
comfyPage: typeof test.prototype.comfyPage
): Promise<boolean> {
return await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph?.constructor?.name === 'Subgraph'
})
}
test.describe('I/O Slot Management', () => {
test('Can add input slots to subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
const vaeEncodeNode = await comfyPage.getNodeRefById('2')
await comfyPage.connectFromSubgraphInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(finalCount).toBe(initialCount + 1)
})
test('Can add output slots to subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
const vaeEncodeNode = await comfyPage.getNodeRefById('2')
await comfyPage.connectToSubgraphOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(finalCount).toBe(initialCount + 1)
})
test('Can remove input slots from subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(initialCount).toBeGreaterThan(0)
await comfyPage.rightClickSubgraphInputSlot()
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
expect(finalCount).toBe(initialCount - 1)
})
test('Can remove output slots from subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(initialCount).toBeGreaterThan(0)
await comfyPage.rightClickSubgraphOutputSlot()
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
expect(finalCount).toBe(initialCount - 1)
})
test('Can rename I/O slots', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
await comfyPage.rightClickSubgraphInputSlot(initialInputLabel)
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can rename input slots via double-click', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
await comfyPage.doubleClickSubgraphInputSlot(initialInputLabel)
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can rename output slots via double-click', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialOutputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.outputs?.[0]?.label || null
})
await comfyPage.doubleClickSubgraphOutputSlot(initialOutputLabel)
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
const renamedOutputName = 'renamed_output'
await comfyPage.page.fill(SELECTORS.promptDialog, renamedOutputName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newOutputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.outputs?.[0]?.label || null
})
expect(newOutputName).toBe(renamedOutputName)
expect(newOutputName).not.toBe(initialOutputLabel)
})
test('Right-click context menu still works alongside double-click', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
// Test that right-click still works for renaming
await comfyPage.rightClickSubgraphInputSlot(initialInputLabel)
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
const rightClickRenamedName = 'right_click_renamed'
await comfyPage.page.fill(SELECTORS.promptDialog, rightClickRenamedName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(rightClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can double-click on slot label text to rename', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialInputLabel = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
// Use direct pointer event approach to double-click on label
await comfyPage.page.evaluate(() => {
const app = window['app']
const graph = app.canvas.graph
const input = graph.inputs?.[0]
if (!input?.labelPos) {
throw new Error('Could not get label position for testing')
}
// Use labelPos for more precise clicking on the text
const testX = input.labelPos[0]
const testY = input.labelPos[1]
const leftClickEvent = {
canvasX: testX,
canvasY: testY,
button: 0, // Left mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
const inputNode = graph.inputNode
if (inputNode?.onPointerDown) {
inputNode.onPointerDown(
leftClickEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
// Trigger double-click if pointer has the handler
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(leftClickEvent)
}
}
})
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
const labelClickRenamedName = 'label_click_renamed'
await comfyPage.page.fill(SELECTORS.promptDialog, labelClickRenamedName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
return graph.inputs?.[0]?.label || null
})
expect(newInputName).toBe(labelClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
})
test('Can create widget from link with compressed target_slot', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraphs/subgraph-compressed-target-slot')
const step = await comfyPage.page.evaluate(() => {
return window['app'].graph.nodes[0].widgets[0].options.step
})
expect(step).toBe(10)
})
})
test.describe('Subgraph Creation and Deletion', () => {
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
const initialNodeCount = await getGraphNodeCount(comfyPage)
await comfyPage.ctrlA()
await comfyPage.nextFrame()
const node = await comfyPage.getNodeRefById('5')
await node.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodes =
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
expect(subgraphNodes.length).toBe(1)
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(1)
})
test('Can delete subgraph node', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
expect(await subgraphNode.exists()).toBe(true)
const initialNodeCount = await getGraphNodeCount(comfyPage)
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(initialNodeCount - 1)
const deletedNode = await comfyPage.getNodeRefById('2')
expect(await deletedNode.exists()).toBe(false)
})
test.describe('Subgraph copy and paste', () => {
test('Can copy subgraph node by dragging + alt', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
// Alt + Click on the subgraph node
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
// Drag slightly to trigger the copy
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
// Find all subgraph nodes
const subgraphNodes =
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
// Expect a second subgraph node to be created (2 total)
expect(subgraphNodes.length).toBe(2)
})
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
// Alt + Click on the subgraph node
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
// Drag slightly to trigger the copy
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
// Find all subgraph nodes and expect all unique IDs
const subgraphNodes =
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
// Expect the second subgraph node to have a unique type
const nodeType1 = await subgraphNodes[0].getType()
const nodeType2 = await subgraphNodes[1].getType()
expect(nodeType1).not.toBe(nodeType2)
})
})
})
test.describe('Operations Inside Subgraphs', () => {
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const initialNodeCount = await getGraphNodeCount(comfyPage)
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
const nodes = window['app'].canvas.graph.nodes
return nodes?.[0]?.id || null
})
expect(nodesInSubgraph).not.toBeNull()
const nodeToClone = await comfyPage.getNodeRefById(
String(nodesInSubgraph)
)
await nodeToClone.click('title')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+c')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+v')
await comfyPage.nextFrame()
const finalNodeCount = await getGraphNodeCount(comfyPage)
expect(finalNodeCount).toBe(initialNodeCount + 1)
})
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Add a node
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
await comfyPage.nextFrame()
// Get initial node count
const initialCount = await getGraphNodeCount(comfyPage)
// Undo
await comfyPage.ctrlZ()
await comfyPage.nextFrame()
const afterUndoCount = await getGraphNodeCount(comfyPage)
expect(afterUndoCount).toBe(initialCount - 1)
// Redo
await comfyPage.ctrlY()
await comfyPage.nextFrame()
const afterRedoCount = await getGraphNodeCount(comfyPage)
expect(afterRedoCount).toBe(initialCount)
})
})
test.describe('Subgraph Navigation and UI', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Breadcrumb updates when subgraph node title is changed', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.getNodeRefById('10')
const nodePos = await subgraphNode.getPosition()
const nodeSize = await subgraphNode.getSize()
// Navigate into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
state: 'visible',
timeout: 20000
})
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb)
const initialBreadcrumbText = await breadcrumb.textContent()
// Go back and edit title
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y - 10
},
delay: 5
})
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
// Navigate back into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
const updatedBreadcrumbText = await breadcrumb.textContent()
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
})
test.describe('DOM Widget Promotion', () => {
test('DOM widget visibility persists through subgraph navigation', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
// Verify promoted widget is visible in parent graph
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.navigateIntoSubgraph()
// Verify widget is visible in subgraph
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
// Navigate back
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Verify widget is still visible
const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(backToParentTextarea).toBeVisible()
await expect(backToParentTextarea).toHaveCount(1)
})
test('DOM widget content is preserved through navigation', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const textarea = comfyPage.page.locator(SELECTORS.domWidget)
await textarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
})
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const initialCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(initialCount).toBe(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(0)
})
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
// Enable new menu for breadcrumb navigation
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
const workflowName = 'subgraphs/subgraph-with-promoted-text-widget'
await comfyPage.loadWorkflow(workflowName)
const textareaCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(textareaCount).toBe(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
// Navigate into subgraph (method now handles retries internally)
await subgraphNode.navigateIntoSubgraph()
await comfyPage.rightClickSubgraphInputSlot('text')
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
// Wait for breadcrumb to be visible
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
state: 'visible',
timeout: 5000
})
// Click breadcrumb to navigate back to parent graph
const homeBreadcrumb = comfyPage.page.getByRole('link', {
// In the subgraph navigation breadcrumbs, the home/top level
// breadcrumb is just the workflow name without the folder path
name: 'subgraph-with-promoted-text-widget'
})
await homeBreadcrumb.waitFor({ state: 'visible' })
await homeBreadcrumb.click()
await comfyPage.nextFrame()
// Check that the subgraph node has no widgets after removing the text slot
const widgetCount = await comfyPage.page.evaluate(() => {
return window['app'].canvas.graph.nodes[0].widgets?.length || 0
})
expect(widgetCount).toBe(0)
})
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
const parentCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(parentCount).toBeGreaterThan(1)
const subgraphNode = await comfyPage.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const subgraphCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(subgraphCount).toBe(parentCount)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(parentCount)
})
})
test.describe('Navigation Hotkeys', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Change the Exit Subgraph keybinding from Escape to Alt+Q
await comfyPage.setSetting('Comfy.Keybinding.NewBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
combo: {
key: 'q',
ctrl: false,
alt: true,
shift: false
}
}
])
await comfyPage.setSetting('Comfy.Keybinding.UnsetBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
combo: {
key: 'Escape',
ctrl: false,
alt: false,
shift: false
}
}
])
// Reload the page
await comfyPage.page.reload()
await comfyPage.page.waitForTimeout(1024)
// Navigate into subgraph
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
expect(await isInSubgraph(comfyPage)).toBe(true)
// Test that Escape no longer exits subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
if (!(await isInSubgraph(comfyPage))) {
throw new Error('Not in subgraph')
}
// Test that Alt+Q now exits subgraph
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
if (!(await isInSubgraph(comfyPage))) {
throw new Error('Not in subgraph')
}
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('.settings-container', {
state: 'visible'
})
// Press Escape - should close dialog, not exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Dialog should be closed
await expect(
comfyPage.page.locator('.settings-container')
).not.toBeVisible()
// Should still be in subgraph
expect(await isInSubgraph(comfyPage)).toBe(true)
// Press Escape again - now should exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
})
})