Files
ComfyUI_frontend/browser_tests/fixtures/helpers/SubgraphHelper.ts
Alexander Brown f2d5bfab73 test(browser): refactor browser tests for reliability and maintainability (#8510)
## Summary

Major refactoring of browser tests to improve reliability,
maintainability, and type safety.

## Changes

### Test Infrastructure Decomposition
- Decomposed `ComfyPage.ts` (~1000 lines) into focused helpers:
- `CanvasHelper`, `DebugHelper`, `SubgraphHelper`,
`NodeOperationsHelper`
- `SettingsHelper`, `WorkflowHelper`, `ClipboardHelper`,
`KeyboardHelper`
- Created `ContextMenu` page object, `BaseDialog` base class, and
`BottomPanel` page object
- Extracted `DefaultGraphPositions` constants

### Locator Stability
- Added `data-testid` attributes to Vue components (sidebar, dialogs,
node library)
- Created centralized `selectors.ts` with test ID constants
- Replaced fragile CSS selectors (`.nth()`, `:nth-child()`) with
`getByTestId`/`getByRole`

### Performance & Reliability
- Removed `setTimeout` anti-patterns (replaced with `waitForFunction`)
- Replaced `waitForTimeout` with retrying assertions
- Replaced hardcoded coordinates with computed `NodeReference` positions
- Enforced LF line endings for all text files

### Type Safety
- Enabled `no-explicit-any` lint rule for browser_tests via oxlint
- Purged `as any` casts from browser_tests
- Added Window type augmentation for standardized window access
- Added proper type annotations throughout

### Bug Fixes
- Restored `ExtensionManager` API contract
- Removed test-only settings from production schema
- Fixed flaky selectors and missing test setup

## Testing
- All browser tests pass
- Typecheck passes


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Tests**
* Overhauled browser E2E test infrastructure with many new
helpers/fixtures, updated test APIs, and CI test container image bumped
for consistency.

* **Chores**
* Standardized line endings and applied stricter lint rules for browser
tests; workspace dependency version updated.

* **Documentation**
* Updated Playwright and TypeScript testing guidance and test-run
commands.

* **UI**
* Added stable data-testids to multiple components to improve
testability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-03 12:29:40 -08:00

326 lines
11 KiB
TypeScript

import type { Page } from '@playwright/test'
import type {
CanvasPointerEvent,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ComfyPage } from '../ComfyPage'
import type { NodeReference } from '../utils/litegraphUtils'
import { SubgraphSlotReference } from '../utils/litegraphUtils'
export class SubgraphHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
/**
* Core helper method for interacting with subgraph I/O slots.
* Handles both input/output slots and both right-click/double-click actions.
*
* @param slotType - 'input' or 'output'
* @param action - 'rightClick' or 'doubleClick'
* @param slotName - Optional specific slot name to target
*/
private async interactWithSubgraphSlot(
slotType: 'input' | 'output',
action: 'rightClick' | 'doubleClick',
slotName?: string
): Promise<void> {
const foundSlot = await this.page.evaluate(
async (params) => {
const { slotType, action, targetSlotName } = params
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'
)
}
const subgraph = currentGraph as Subgraph
// Get the appropriate node and slots
const node =
slotType === 'input' ? subgraph.inputNode : subgraph.outputNode
const slots = slotType === 'input' ? subgraph.inputs : subgraph.outputs
if (!node) {
throw new Error(`No ${slotType} node found in subgraph`)
}
if (!slots || slots.length === 0) {
throw new Error(`No ${slotType} slots found in subgraph`)
}
// Filter slots based on target name and action type
const slotsToTry = targetSlotName
? slots.filter((slot) => slot.name === targetSlotName)
: action === 'rightClick'
? slots
: [slots[0]] // Right-click tries all, double-click uses first
if (slotsToTry.length === 0) {
throw new Error(
targetSlotName
? `${slotType} slot '${targetSlotName}' not found`
: `No ${slotType} slots available to try`
)
}
// Handle the interaction based on action type
if (action === 'rightClick') {
// Right-click: try each slot until one works
for (const slot of slotsToTry) {
if (!slot.pos) continue
const event = {
canvasX: slot.pos[0],
canvasY: slot.pos[1],
button: 2, // Right mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
if (node.onPointerDown) {
node.onPointerDown(
event as unknown as CanvasPointerEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
return {
success: true,
slotName: slot.name,
x: slot.pos[0],
y: slot.pos[1]
}
}
}
} else if (action === 'doubleClick') {
// Double-click: use first slot with bounding rect center
const slot = slotsToTry[0]
if (!slot.boundingRect) {
throw new Error(`${slotType} slot bounding rect not found`)
}
const rect = slot.boundingRect
const testX = rect[0] + rect[2] / 2 // x + width/2
const testY = rect[1] + rect[3] / 2 // y + height/2
const event = {
canvasX: testX,
canvasY: testY,
button: 0, // Left mouse button
preventDefault: () => {},
stopPropagation: () => {}
}
if (node.onPointerDown) {
node.onPointerDown(
event as unknown as CanvasPointerEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
// Trigger double-click
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(
event as unknown as CanvasPointerEvent
)
}
}
return { success: true, slotName: slot.name, x: testX, y: testY }
}
return { success: false }
},
{ slotType, action, targetSlotName: slotName }
)
if (!foundSlot.success) {
const actionText =
action === 'rightClick' ? 'open context menu for' : 'double-click'
throw new Error(
slotName
? `Could not ${actionText} ${slotType} slot '${slotName}'`
: `Could not find any ${slotType} slot to ${actionText}`
)
}
// Wait for the appropriate UI element to appear
if (action === 'rightClick') {
await this.page.waitForSelector('.litemenu-entry', {
state: 'visible',
timeout: 5000
})
} else {
await this.comfyPage.nextFrame()
}
}
/**
* Right-clicks on a subgraph input slot to open the context menu.
* Must be called when inside a subgraph.
*
* This method uses the actual slot positions from the subgraph.inputs array,
* which contain the correct coordinates for each input slot. These positions
* are different from the visual node positions and are specifically where
* the slots are rendered on the input node.
*
* @param inputName Optional name of the specific input slot to target (e.g., 'text').
* If not provided, tries all available input slots until one works.
* @returns Promise that resolves when the context menu appears
*/
async rightClickInputSlot(inputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('input', 'rightClick', inputName)
}
/**
* Right-clicks on a subgraph output slot to open the context menu.
* Must be called when inside a subgraph.
*
* Similar to rightClickInputSlot but for output slots.
*
* @param outputName Optional name of the specific output slot to target.
* If not provided, tries all available output slots until one works.
* @returns Promise that resolves when the context menu appears
*/
async rightClickOutputSlot(outputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('output', 'rightClick', outputName)
}
/**
* 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 doubleClickInputSlot(inputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('input', 'doubleClick', inputName)
}
/**
* 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 doubleClickOutputSlot(outputName?: string): Promise<void> {
return this.interactWithSubgraphSlot('output', 'doubleClick', outputName)
}
/**
* Get a reference to a subgraph input slot
*/
getInputSlot(slotName?: string): SubgraphSlotReference {
return new SubgraphSlotReference('input', slotName || '', this.comfyPage)
}
/**
* Get a reference to a subgraph output slot
*/
getOutputSlot(slotName?: string): SubgraphSlotReference {
return new SubgraphSlotReference('output', slotName || '', this.comfyPage)
}
/**
* Connect a regular node output to a subgraph input.
* This creates a new input slot on the subgraph if targetInputName is not provided.
*/
async connectToInput(
sourceNode: NodeReference,
sourceSlotIndex: number,
targetInputName?: string
): Promise<void> {
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
const targetSlot = this.getInputSlot(targetInputName)
const targetPosition = targetInputName
? await targetSlot.getPosition() // Connect to existing slot
: await targetSlot.getOpenSlotPosition() // Create new slot
await this.comfyPage.canvasOps.dragAndDrop(
await sourceSlot.getPosition(),
targetPosition
)
await this.comfyPage.nextFrame()
}
/**
* Connect a subgraph input to a regular node input.
* This creates a new input slot on the subgraph if sourceInputName is not provided.
*/
async connectFromInput(
targetNode: NodeReference,
targetSlotIndex: number,
sourceInputName?: string
): Promise<void> {
const sourceSlot = this.getInputSlot(sourceInputName)
const targetSlot = await targetNode.getInput(targetSlotIndex)
const sourcePosition = sourceInputName
? await sourceSlot.getPosition() // Connect from existing slot
: await sourceSlot.getOpenSlotPosition() // Create new slot
const targetPosition = await targetSlot.getPosition()
await this.comfyPage.canvasOps.dragAndDrop(sourcePosition, targetPosition)
await this.comfyPage.nextFrame()
}
/**
* Connect a regular node output to a subgraph output.
* This creates a new output slot on the subgraph if targetOutputName is not provided.
*/
async connectToOutput(
sourceNode: NodeReference,
sourceSlotIndex: number,
targetOutputName?: string
): Promise<void> {
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
const targetSlot = this.getOutputSlot(targetOutputName)
const targetPosition = targetOutputName
? await targetSlot.getPosition() // Connect to existing slot
: await targetSlot.getOpenSlotPosition() // Create new slot
await this.comfyPage.canvasOps.dragAndDrop(
await sourceSlot.getPosition(),
targetPosition
)
await this.comfyPage.nextFrame()
}
/**
* Connect a subgraph output to a regular node input.
* This creates a new output slot on the subgraph if sourceOutputName is not provided.
*/
async connectFromOutput(
targetNode: NodeReference,
targetSlotIndex: number,
sourceOutputName?: string
): Promise<void> {
const sourceSlot = this.getOutputSlot(sourceOutputName)
const targetSlot = await targetNode.getInput(targetSlotIndex)
const sourcePosition = sourceOutputName
? await sourceSlot.getPosition() // Connect from existing slot
: await sourceSlot.getOpenSlotPosition() // Create new slot
await this.comfyPage.canvasOps.dragAndDrop(
sourcePosition,
await targetSlot.getPosition()
)
await this.comfyPage.nextFrame()
}
}