Files
ComfyUI_frontend/browser_tests/fixtures/helpers/CanvasHelper.ts
Christian Byrne b2f848893a test: add perf test for viewport pan sweep GC churn (#10479)
## Summary

Adds a `@perf` test to establish a baseline for viewport panning GC
churn on large graphs.

## Changes

- **What**: New `large graph viewport pan sweep` perf test that pans
aggressively back and forth across a 245-node graph, forcing many nodes
to cross the viewport boundary. Measures style recalcs, forced layouts,
task duration, heap delta, and DOM node count.

## Review Focus

This is **PR 1 of 2** (perf-fix-with-proof pattern). The fix (viewport
culling) will follow in a separate PR once this baseline is established
on main. CI will then show the delta proving the improvement.

The test uses 120 steps out + 120 steps back at 8px/step = ~960px total
displacement, enough to sweep across a significant portion of the large
graph layout.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10479-test-add-perf-test-for-viewport-pan-sweep-GC-churn-32d6d73d365081cc9f15fe3d5890675d)
by [Unito](https://www.unito.io)
2026-03-28 13:54:26 -07:00

224 lines
6.6 KiB
TypeScript

import type { Locator, Page } from '@playwright/test'
import { DefaultGraphPositions } from '../constants/defaultGraphPositions'
import type { Position } from '../types'
export class CanvasHelper {
constructor(
private page: Page,
private canvas: Locator,
private resetViewButton: Locator
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<number>(requestAnimationFrame)
})
}
async resetView(): Promise<void> {
if (await this.resetViewButton.isVisible()) {
await this.resetViewButton.click()
}
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
async zoom(deltaY: number, steps: number = 1): Promise<void> {
await this.page.mouse.move(10, 10)
for (let i = 0; i < steps; i++) {
await this.page.mouse.wheel(0, deltaY)
}
await this.nextFrame()
}
async pan(offset: Position, safeSpot?: Position): Promise<void> {
safeSpot = safeSpot || { x: 10, y: 10 }
await this.page.mouse.move(safeSpot.x, safeSpot.y)
await this.page.mouse.down()
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
await this.page.mouse.up()
await this.nextFrame()
}
async panWithTouch(offset: Position, safeSpot?: Position): Promise<void> {
safeSpot = safeSpot || { x: 10, y: 10 }
const client = await this.page.context().newCDPSession(this.page)
await client.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [safeSpot]
})
await client.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: offset.x + safeSpot.x, y: offset.y + safeSpot.y }]
})
await client.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: []
})
await this.nextFrame()
}
async rightClick(x: number = 10, y: number = 10): Promise<void> {
await this.page.mouse.click(x, y, { button: 'right' })
await this.nextFrame()
}
async doubleClick(): Promise<void> {
await this.page.mouse.dblclick(10, 10, { delay: 5 })
await this.nextFrame()
}
async click(position: Position): Promise<void> {
await this.canvas.click({ position })
await this.nextFrame()
}
async clickEmptySpace(): Promise<void> {
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
await this.nextFrame()
}
async dragAndDrop(source: Position, target: Position): Promise<void> {
await this.page.mouse.move(source.x, source.y)
await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y, { steps: 100 })
await this.page.mouse.up()
await this.nextFrame()
}
async moveMouseToEmptyArea(): Promise<void> {
await this.page.mouse.move(10, 10)
}
async isReadOnly(): Promise<boolean> {
return this.page.evaluate(() => {
return window.app!.canvas.state.readOnly
})
}
async getScale(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.ds.scale
})
}
async setScale(scale: number): Promise<void> {
await this.page.evaluate((s) => {
window.app!.canvas.ds.scale = s
}, scale)
await this.nextFrame()
}
async convertOffsetToCanvas(
pos: [number, number]
): Promise<[number, number]> {
return this.page.evaluate((pos) => {
return window.app!.canvas.ds.convertOffsetToCanvas(pos)
}, pos)
}
async getNodeCenterByTitle(title: string): Promise<Position | null> {
return this.page.evaluate((title) => {
const app = window.app!
const node = app.graph.nodes.find(
(n: { title: string }) => n.title === title
)
if (!node) return null
const centerX = node.pos[0] + node.size[0] / 2
const centerY = node.pos[1] + node.size[1] / 2
const [clientX, clientY] = app.canvasPosToClientPos([centerX, centerY])
return { x: clientX, y: clientY }
}, title)
}
async getGroupPosition(title: string): Promise<Position> {
const pos = await this.page.evaluate((title) => {
const groups = window.app!.graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
return { x: group.pos[0], y: group.pos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
async dragGroup(options: {
name: string
deltaX: number
deltaY: number
}): Promise<void> {
const { name, deltaX, deltaY } = options
const screenPos = await this.page.evaluate((title) => {
const app = window.app!
const groups = app.graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, name)
if (!screenPos) throw new Error(`Group "${name}" not found`)
await this.dragAndDrop(screenPos, {
x: screenPos.x + deltaX,
y: screenPos.y + deltaY
})
}
/**
* Pan the canvas back and forth in a sweep pattern using middle-mouse drag.
* Each step advances one animation frame, giving per-frame measurement
* granularity for performance tests.
*/
async panSweep(options?: {
steps?: number
dx?: number
dy?: number
}): Promise<void> {
const { steps = 120, dx = 8, dy = 3 } = options ?? {}
const box = await this.canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
await this.page.mouse.move(centerX, centerY)
await this.page.mouse.down({ button: 'middle' })
// Sweep forward
for (let i = 0; i < steps; i++) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await this.nextFrame()
}
// Sweep back
for (let i = steps; i > 0; i--) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await this.nextFrame()
}
await this.page.mouse.up({ button: 'middle' })
}
async disconnectEdge(): Promise<void> {
await this.dragAndDrop(
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
DefaultGraphPositions.emptySpace
)
}
async connectEdge(options: { reverse?: boolean } = {}): Promise<void> {
const { reverse = false } = options
const start = reverse
? DefaultGraphPositions.clipTextEncodeNode1InputSlot
: DefaultGraphPositions.loadCheckpointNodeClipOutputSlot
const end = reverse
? DefaultGraphPositions.loadCheckpointNodeClipOutputSlot
: DefaultGraphPositions.clipTextEncodeNode1InputSlot
await this.dragAndDrop(start, end)
}
}