Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions browser_tests/fixtures/ComfyPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,160 @@ export class ComfyPage {
})
}

/**
* Double-clicks on a subgraph input slot to rename it.
* Must be called when inside a subgraph.
*
* @param inputName Optional name of the specific input slot to target (e.g., 'text').
* If not provided, tries the first available input slot.
* @returns Promise that resolves when the rename dialog appears
*/
async doubleClickSubgraphInputSlot(inputName?: string): Promise<void> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will using this work?

export class SubgraphSlotReference {
constructor(
readonly type: 'input' | 'output',
readonly slotName: string,
readonly comfyPage: ComfyPage
) {}
async getPosition(): Promise<Position> {
const pos: [number, number] = await this.comfyPage.page.evaluate(

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just referenced the impl of rightClickSubgraphOutputSlot to directly trigger the pointer event.

const foundSlot = await this.page.evaluate(async (targetInputName) => {
const app = window['app']
const currentGraph = app.canvas.graph

// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}

// Get the input node
const inputNode = currentGraph.inputNode
if (!inputNode) {
throw new Error('No input node found in subgraph')
}

// Get available inputs
const inputs = currentGraph.inputs
if (!inputs || inputs.length === 0) {
throw new Error('No input slots found in subgraph')
}

// Filter to specific input if requested, otherwise use first input
const inputsToTry = targetInputName
? inputs.filter((inp) => inp.name === targetInputName)
: [inputs[0]]

if (inputsToTry.length === 0) {
throw new Error(
targetInputName
? `Input slot '${targetInputName}' not found`
: 'No input slots available to try'
)
}

const input = inputsToTry[0]
if (!input.pos) {
throw new Error('Input slot position not found')
}

const testX = input.pos[0]
const testY = input.pos[1]

return { success: true, inputName: input.name, x: testX, y: testY }
}, inputName)

if (!foundSlot.success) {
throw new Error(
inputName
? `Could not double-click input slot '${inputName}'`
: 'Could not find any input slot to double-click'
)
}

// Perform the actual double-click at the slot position
await this.canvas.dblclick({
position: { x: foundSlot.x, y: foundSlot.y }
})
await this.nextFrame()

// Wait for the rename dialog to appear
await this.page.waitForSelector('.graphdialog input', {
state: 'visible',
timeout: 5000
})
}

/**
* Double-clicks on a subgraph output slot to rename it.
* Must be called when inside a subgraph.
*
* @param outputName Optional name of the specific output slot to target.
* If not provided, tries the first available output slot.
* @returns Promise that resolves when the rename dialog appears
*/
async doubleClickSubgraphOutputSlot(outputName?: string): Promise<void> {
const foundSlot = await this.page.evaluate(async (targetOutputName) => {
const app = window['app']
const currentGraph = app.canvas.graph

// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
}

// Get the output node
const outputNode = currentGraph.outputNode
if (!outputNode) {
throw new Error('No output node found in subgraph')
}

// Get available outputs
const outputs = currentGraph.outputs
if (!outputs || outputs.length === 0) {
throw new Error('No output slots found in subgraph')
}

// Filter to specific output if requested, otherwise use first output
const outputsToTry = targetOutputName
? outputs.filter((out) => out.name === targetOutputName)
: [outputs[0]]

if (outputsToTry.length === 0) {
throw new Error(
targetOutputName
? `Output slot '${targetOutputName}' not found`
: 'No output slots available to try'
)
}

const output = outputsToTry[0]
if (!output.pos) {
throw new Error('Output slot position not found')
}

const testX = output.pos[0]
const testY = output.pos[1]

return { success: true, outputName: output.name, x: testX, y: testY }
}, outputName)

if (!foundSlot.success) {
throw new Error(
outputName
? `Could not double-click output slot '${outputName}'`
: 'Could not find any output slot to double-click'
)
}

// Perform the actual double-click at the slot position
await this.canvas.dblclick({
position: { x: foundSlot.x, y: foundSlot.y }
})
await this.nextFrame()

// Wait for the rename dialog to appear
await this.page.waitForSelector('.graphdialog input', {
state: 'visible',
timeout: 5000
})
}

/**
* Get a reference to a subgraph input slot
*/
Expand Down
154 changes: 154 additions & 0 deletions browser_tests/tests/subgraph.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,160 @@ test.describe('Subgraph Operations', () => {
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
})

test('Can rename input slots via double-click', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('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('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('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('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
})

// Get the label position (not the connector position) and double-click there
const labelPosition = await comfyPage.page.evaluate(() => {
const graph = window['app'].canvas.graph
const input = graph.inputs?.[0]
if (!input?.labelPos) return null
// labelPos gives us the text label position, which should be different from pos (connector position)
return { x: input.labelPos[0], y: input.labelPos[1] }
})

if (!labelPosition) {
throw new Error('Could not get label position for testing')
}

// Double-click on the label text specifically
await comfyPage.canvas.dblclick({
position: labelPosition
})
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.describe('Subgraph Creation and Deletion', () => {
Expand Down
44 changes: 34 additions & 10 deletions src/lib/litegraph/src/subgraph/SubgraphIONodeBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,21 @@ export abstract class SubgraphIONodeBase<
}
}

/**
* Handles double-click on an IO slot to rename it.
* @param slot The slot that was double-clicked.
* @param event The event that triggered the double-click.
*/
protected handleSlotDoubleClick(
slot: TSlot,
event: CanvasPointerEvent
): void {
// Only allow renaming non-empty slots
if (slot !== this.emptySlot) {
this.#promptForSlotRename(slot, event)
}
}

/**
* Shows the context menu for an IO slot.
* @param slot The slot to show the context menu for.
Expand Down Expand Up @@ -239,23 +254,32 @@ export abstract class SubgraphIONodeBase<
// Rename the slot
case 'rename':
if (slot !== this.emptySlot) {
this.subgraph.canvasAction((c) =>
c.prompt(
'Slot name',
slot.name,
(newName: string) => {
if (newName) this.renameSlot(slot, newName)
},
event
)
)
this.#promptForSlotRename(slot, event)
}
break
}

this.subgraph.setDirtyCanvas(true)
}

/**
* Prompts the user to rename a slot.
* @param slot The slot to rename.
* @param event The event that triggered the rename.
*/
#promptForSlotRename(slot: TSlot, event: CanvasPointerEvent): void {
this.subgraph.canvasAction((c) =>
c.prompt(
'Slot name',
slot.name,
(newName: string) => {
if (newName) this.renameSlot(slot, newName)
},
event
)
)
}

/** Arrange the slots in this node. */
arrange(): void {
const { minWidth, roundedRadius } = SubgraphIONodeBase
Expand Down
Loading
Loading