mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 03:01:54 +00:00
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>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
@@ -22,10 +23,10 @@ export class SubgraphSlotReference {
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos: [number, number] = await this.comfyPage.page.evaluate(
|
||||
([type, slotName]) => {
|
||||
const currentGraph = window['app'].canvas.graph
|
||||
const currentGraph = window.app!.canvas.graph!
|
||||
|
||||
// Check if we're in a subgraph
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
// Check if we're in a subgraph (subgraphs have inputNode property)
|
||||
if (!('inputNode' in currentGraph)) {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
@@ -51,7 +52,7 @@ export class SubgraphSlotReference {
|
||||
}
|
||||
|
||||
// Convert from offset to canvas coordinates
|
||||
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
|
||||
const canvasPos = window.app!.canvas.ds.convertOffsetToCanvas([
|
||||
slot.pos[0],
|
||||
slot.pos[1]
|
||||
])
|
||||
@@ -69,9 +70,10 @@ export class SubgraphSlotReference {
|
||||
async getOpenSlotPosition(): Promise<Position> {
|
||||
const pos: [number, number] = await this.comfyPage.page.evaluate(
|
||||
([type]) => {
|
||||
const currentGraph = window['app'].canvas.graph
|
||||
const currentGraph = window.app!.canvas.graph!
|
||||
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
// Check if we're in a subgraph (subgraphs have inputNode property)
|
||||
if (!('inputNode' in currentGraph)) {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
@@ -85,7 +87,7 @@ export class SubgraphSlotReference {
|
||||
}
|
||||
|
||||
// Convert from offset to canvas coordinates
|
||||
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
|
||||
const canvasPos = window.app!.canvas.ds.convertOffsetToCanvas([
|
||||
node.emptySlot.pos[0],
|
||||
node.emptySlot.pos[1]
|
||||
])
|
||||
@@ -111,12 +113,12 @@ class NodeSlotReference {
|
||||
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
// Use canvas.graph to get the current graph (works in both main graph and subgraphs)
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
|
||||
const rawPos = node.getConnectionPos(type === 'input', index)
|
||||
const convertedPos =
|
||||
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
|
||||
window.app!.canvas.ds!.convertOffsetToCanvas(rawPos)
|
||||
|
||||
// Debug logging - convert Float64Arrays to regular arrays for visibility
|
||||
console.warn(
|
||||
@@ -126,7 +128,7 @@ class NodeSlotReference {
|
||||
nodeSize: [node.size[0], node.size[1]],
|
||||
rawConnectionPos: [rawPos[0], rawPos[1]],
|
||||
convertedPos: [convertedPos[0], convertedPos[1]],
|
||||
currentGraphType: window['app'].canvas.graph.constructor.name
|
||||
currentGraphType: window.app!.canvas.graph!.constructor.name
|
||||
}
|
||||
)
|
||||
|
||||
@@ -142,7 +144,7 @@ class NodeSlotReference {
|
||||
async getLinkCount() {
|
||||
return await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
if (type === 'input') {
|
||||
return node.inputs[index].link == null ? 0 : 1
|
||||
@@ -155,7 +157,7 @@ class NodeSlotReference {
|
||||
async removeLinks() {
|
||||
await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
if (type === 'input') {
|
||||
node.disconnectInput(index)
|
||||
@@ -180,15 +182,15 @@ class NodeWidgetReference {
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
|
||||
([id, index]) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
const widget = node.widgets[index]
|
||||
const widget = node.widgets![index]
|
||||
if (!widget) throw new Error(`Widget ${index} not found.`)
|
||||
|
||||
const [x, y, w, h] = node.getBounding()
|
||||
return window['app'].canvasPosToClientPos([
|
||||
const [x, y, w, _h] = node.getBounding()
|
||||
return window.app!.canvasPosToClientPos([
|
||||
x + w / 2,
|
||||
y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1
|
||||
y + window.LiteGraph!['NODE_TITLE_HEIGHT'] + widget.last_y! + 1
|
||||
])
|
||||
},
|
||||
[this.node.id, this.index] as const
|
||||
@@ -205,9 +207,9 @@ class NodeWidgetReference {
|
||||
async getSocketPosition(): Promise<Position> {
|
||||
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
|
||||
([id, index]) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
const node = window.app!.graph!.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
const widget = node.widgets[index]
|
||||
const widget = node.widgets![index]
|
||||
if (!widget) throw new Error(`Widget ${index} not found.`)
|
||||
|
||||
const slot = node.inputs.find(
|
||||
@@ -216,9 +218,9 @@ class NodeWidgetReference {
|
||||
if (!slot) throw new Error(`Socket ${widget.name} not found.`)
|
||||
|
||||
const [x, y] = node.getBounding()
|
||||
return window['app'].canvasPosToClientPos([
|
||||
x + slot.pos[0],
|
||||
y + slot.pos[1] + window['LiteGraph']['NODE_TITLE_HEIGHT']
|
||||
return window.app!.canvasPosToClientPos([
|
||||
x + slot.pos![0],
|
||||
y + slot.pos![1] + window.LiteGraph!['NODE_TITLE_HEIGHT']
|
||||
])
|
||||
},
|
||||
[this.node.id, this.index] as const
|
||||
@@ -239,7 +241,7 @@ class NodeWidgetReference {
|
||||
const pos = await this.getPosition()
|
||||
const canvas = this.node.comfyPage.canvas
|
||||
const canvasPos = (await canvas.boundingBox())!
|
||||
await this.node.comfyPage.dragAndDrop(
|
||||
await this.node.comfyPage.canvasOps.dragAndDrop(
|
||||
{
|
||||
x: canvasPos.x + pos.x,
|
||||
y: canvasPos.y + pos.y
|
||||
@@ -254,9 +256,9 @@ class NodeWidgetReference {
|
||||
async getValue() {
|
||||
return await this.node.comfyPage.page.evaluate(
|
||||
([id, index]) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
const node = window.app!.graph!.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
const widget = node.widgets[index]
|
||||
const widget = node.widgets![index]
|
||||
if (!widget) throw new Error(`Widget ${index} not found.`)
|
||||
return widget.value
|
||||
},
|
||||
@@ -271,7 +273,7 @@ export class NodeReference {
|
||||
) {}
|
||||
async exists(): Promise<boolean> {
|
||||
return await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
return !!node
|
||||
}, this.id)
|
||||
}
|
||||
@@ -279,7 +281,7 @@ export class NodeReference {
|
||||
return this.getProperty('type')
|
||||
}
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos = await this.comfyPage.convertOffsetToCanvas(
|
||||
const pos = await this.comfyPage.canvasOps.convertOffsetToCanvas(
|
||||
await this.getProperty<[number, number]>('pos')
|
||||
)
|
||||
return {
|
||||
@@ -288,12 +290,11 @@ export class NodeReference {
|
||||
}
|
||||
}
|
||||
async getBounding(): Promise<Position & Size> {
|
||||
const [x, y, width, height]: [number, number, number, number] =
|
||||
await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
if (!node) throw new Error('Node not found')
|
||||
return node.getBounding()
|
||||
}, this.id)
|
||||
const [x, y, width, height] = await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) throw new Error('Node not found')
|
||||
return [...node.getBounding()] as [number, number, number, number]
|
||||
}, this.id)
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
@@ -311,6 +312,11 @@ export class NodeReference {
|
||||
async getFlags(): Promise<{ collapsed?: boolean; pinned?: boolean }> {
|
||||
return await this.getProperty('flags')
|
||||
}
|
||||
async getTitlePosition(): Promise<Position> {
|
||||
const nodePos = await this.getPosition()
|
||||
const nodeSize = await this.getSize()
|
||||
return { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
|
||||
}
|
||||
async isPinned() {
|
||||
return !!(await this.getFlags()).pinned
|
||||
}
|
||||
@@ -323,9 +329,9 @@ export class NodeReference {
|
||||
async getProperty<T>(prop: string): Promise<T> {
|
||||
return await this.comfyPage.page.evaluate(
|
||||
([id, prop]) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) throw new Error('Node not found')
|
||||
return node[prop]
|
||||
return (node as unknown as Record<string, T>)[prop]
|
||||
},
|
||||
[this.id, prop] as const
|
||||
)
|
||||
@@ -343,16 +349,16 @@ export class NodeReference {
|
||||
position: 'title' | 'collapse',
|
||||
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
|
||||
) {
|
||||
const nodePos = await this.getPosition()
|
||||
const nodeSize = await this.getSize()
|
||||
let clickPos: Position
|
||||
switch (position) {
|
||||
case 'title':
|
||||
clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
|
||||
clickPos = await this.getTitlePosition()
|
||||
break
|
||||
case 'collapse':
|
||||
case 'collapse': {
|
||||
const nodePos = await this.getPosition()
|
||||
clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 }
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error(`Invalid click position ${position}`)
|
||||
}
|
||||
@@ -369,12 +375,12 @@ export class NodeReference {
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
if (moveMouseToEmptyArea) {
|
||||
await this.comfyPage.moveMouseToEmptyArea()
|
||||
await this.comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
}
|
||||
}
|
||||
async copy() {
|
||||
await this.click('title')
|
||||
await this.comfyPage.ctrlC()
|
||||
await this.comfyPage.clipboard.copy()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async connectWidget(
|
||||
@@ -384,7 +390,7 @@ export class NodeReference {
|
||||
) {
|
||||
const originSlot = await this.getOutput(originSlotIndex)
|
||||
const targetWidget = await targetNode.getWidget(targetWidgetIndex)
|
||||
await this.comfyPage.dragAndDrop(
|
||||
await this.comfyPage.canvasOps.dragAndDrop(
|
||||
await originSlot.getPosition(),
|
||||
await targetWidget.getSocketPosition()
|
||||
)
|
||||
@@ -397,7 +403,7 @@ export class NodeReference {
|
||||
) {
|
||||
const originSlot = await this.getOutput(originSlotIndex)
|
||||
const targetSlot = await targetNode.getInput(targetSlotIndex)
|
||||
await this.comfyPage.dragAndDrop(
|
||||
await this.comfyPage.canvasOps.dragAndDrop(
|
||||
await originSlot.getPosition(),
|
||||
await targetSlot.getPosition()
|
||||
)
|
||||
@@ -415,9 +421,9 @@ export class NodeReference {
|
||||
}
|
||||
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
|
||||
await this.clickContextMenuOption('Convert to Group Node')
|
||||
await this.comfyPage.fillPromptDialog(groupNodeName)
|
||||
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
|
||||
await this.comfyPage.nextFrame()
|
||||
const nodes = await this.comfyPage.getNodeRefsByType(
|
||||
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
|
||||
`workflow>${groupNodeName}`
|
||||
)
|
||||
if (nodes.length !== 1) {
|
||||
@@ -428,7 +434,8 @@ export class NodeReference {
|
||||
async convertToSubgraph() {
|
||||
await this.clickContextMenuOption('Convert to Subgraph')
|
||||
await this.comfyPage.nextFrame()
|
||||
const nodes = await this.comfyPage.getNodeRefsByTitle('New Subgraph')
|
||||
const nodes =
|
||||
await this.comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
|
||||
if (nodes.length !== 1) {
|
||||
throw new Error(
|
||||
`Did not find single subgraph node (found=${nodes.length})`
|
||||
@@ -446,7 +453,7 @@ export class NodeReference {
|
||||
}
|
||||
async navigateIntoSubgraph() {
|
||||
const titleHeight = await this.comfyPage.page.evaluate(() => {
|
||||
return window['LiteGraph']['NODE_TITLE_HEIGHT']
|
||||
return window.LiteGraph!['NODE_TITLE_HEIGHT']
|
||||
})
|
||||
const nodePos = await this.getPosition()
|
||||
const nodeSize = await this.getSize()
|
||||
@@ -458,13 +465,14 @@ export class NodeReference {
|
||||
{ x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 }
|
||||
]
|
||||
|
||||
let isInSubgraph = false
|
||||
let attempts = 0
|
||||
const maxAttempts = 3
|
||||
|
||||
while (!isInSubgraph && attempts < maxAttempts) {
|
||||
attempts++
|
||||
const checkIsInSubgraph = async () => {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return graph?.constructor?.name === 'Subgraph'
|
||||
})
|
||||
}
|
||||
|
||||
await expect(async () => {
|
||||
for (const position of clickPositions) {
|
||||
// Clear any selection first
|
||||
await this.comfyPage.canvas.click({
|
||||
@@ -477,24 +485,9 @@ export class NodeReference {
|
||||
await this.comfyPage.canvas.dblclick({ position, force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
// Check if we successfully entered the subgraph
|
||||
isInSubgraph = await this.comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph?.constructor?.name === 'Subgraph'
|
||||
})
|
||||
|
||||
if (isInSubgraph) break
|
||||
if (await checkIsInSubgraph()) return
|
||||
}
|
||||
|
||||
if (!isInSubgraph && attempts < maxAttempts) {
|
||||
await this.comfyPage.page.waitForTimeout(500)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInSubgraph) {
|
||||
throw new Error(
|
||||
'Failed to navigate into subgraph after ' + attempts + ' attempts'
|
||||
)
|
||||
}
|
||||
throw new Error('Not in subgraph yet')
|
||||
}).toPass({ timeout: 5000, intervals: [100, 200, 500] })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
||||
@@ -40,7 +39,7 @@ export class VueNodeFixture {
|
||||
async setTitle(value: string): Promise<void> {
|
||||
await this.header.dblclick()
|
||||
const input = this.titleInput
|
||||
await expect(input).toBeVisible()
|
||||
await input.waitFor({ state: 'visible' })
|
||||
await input.fill(value)
|
||||
await input.press('Enter')
|
||||
}
|
||||
@@ -48,7 +47,7 @@ export class VueNodeFixture {
|
||||
async cancelTitleEdit(): Promise<void> {
|
||||
await this.header.dblclick()
|
||||
const input = this.titleInput
|
||||
await expect(input).toBeVisible()
|
||||
await input.waitFor({ state: 'visible' })
|
||||
await input.press('Escape')
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user