Files
ComfyUI_frontend/browser_tests/fixtures/helpers/BuilderSelectHelper.ts
Alexander Brown 0353524e6f refactor: standardize Page Object locators as public readonly instead of getters (#11135)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Convert ~120 getter-based locators across 18 browser test fixture
files to `public readonly` constructor-assigned properties
- Removes unnecessary indirection, makes object shape explicit, and
improves IDE auto-complete / type inference
- Keeps lazy-init getters (`??=`), computed properties, and `private get
page()` convenience accessors as getters

## Changes

**`browser_tests/fixtures/components/`** (6 files):
`ComfyNodeSearchBox`, `ContextMenu`, `SettingDialog`, `SignInDialog`,
`SidebarTab` (all 6 classes), `Topbar`

**`browser_tests/fixtures/`** (4 files): `ComfyPage` (ComfyMenu.buttons,
ComfyPage.visibleToasts), `UserSelectPage`, `ComfyMouse`,
`VueNodeHelpers`

**`browser_tests/fixtures/helpers/`** (7 files): `AppModeHelper`,
`BuilderFooterHelper`, `BuilderSaveAsHelper`, `BuilderSelectHelper`,
`BuilderStepsHelper`, `ToastHelper`, `NodeOperationsHelper`

**`browser_tests/fixtures/utils/`** (1 file): `vueNodeFixtures`

## Validation

- `pnpm typecheck` 
- `pnpm typecheck:browser` 
- `pnpm exec eslint browser_tests/fixtures/` 
- All pre-commit hooks pass (oxfmt, oxlint, eslint, typecheck,
typecheck:browser) 
- No visual/manual verification needed — changes are test fixture
locator declarations only (no UI or runtime behavior change)

Fixes #11131

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11135-refactor-standardize-Page-Object-locators-as-public-readonly-instead-of-getters-33e6d73d3650819690cbc639f3d30daf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-10 21:10:17 +00:00

206 lines
6.9 KiB
TypeScript

import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* Drag an element from one index to another within a list of locators.
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
*
* DraggableList toggles position when the dragged item's center crosses
* past an idle item's center. To reliably land at the target position,
* we overshoot slightly past the target's far edge.
*/
async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
const fromBox = await items.nth(fromIndex).boundingBox()
const toBox = await items.nth(toIndex).boundingBox()
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
const draggingDown = toIndex > fromIndex
const targetY = draggingDown
? toBox.y + toBox.height * 0.9
: toBox.y + toBox.height * 0.1
const page = items.page()
await page.mouse.move(
fromBox.x + fromBox.width / 2,
fromBox.y + fromBox.height / 2
)
await page.mouse.down()
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
await page.mouse.up()
}
export class BuilderSelectHelper {
/** All IoItem locators in the current step sidebar. */
public readonly inputItems: Locator
/** All IoItem title locators in the inputs step sidebar. */
public readonly inputItemTitles: Locator
/** All widget label locators in the preview/arrange sidebar. */
public readonly previewWidgetLabels: Locator
constructor(private readonly comfyPage: ComfyPage) {
this.inputItems = this.page.getByTestId(TestIds.builder.ioItem)
this.inputItemTitles = this.page.getByTestId(TestIds.builder.ioItemTitle)
this.previewWidgetLabels = this.page.getByTestId(
TestIds.builder.widgetLabel
)
}
private get page(): Page {
return this.comfyPage.page
}
/**
* Get the actions menu trigger for a builder IoItem (input-select sidebar).
* @param title The widget title shown in the IoItem.
*/
getInputItemMenu(title: string): Locator {
return this.inputItems
.filter({
has: this.inputItemTitles.getByText(title, { exact: true })
})
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Get the actions menu trigger for a widget in the preview/arrange sidebar.
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
*/
getPreviewWidgetMenu(ariaLabel: string): Locator {
return this.page
.getByLabel(ariaLabel, { exact: true })
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/** Delete a builder input via its actions menu. */
async deleteInput(title: string) {
const menu = this.getInputItemMenu(title)
await menu.click()
await this.page.getByText('Delete', { exact: true }).click()
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem via the popover menu "Rename" action.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameInputViaMenu(title: string, newName: string) {
const menu = this.getInputItemMenu(title)
await menu.click()
await this.page.getByText('Rename', { exact: true }).click()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem by double-clicking its title for inline editing.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameInput(title: string, newName: string) {
const titleEl = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByText(title, { exact: true })
await titleEl.dblclick()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
/**
* Rename a widget via its actions popover (works in preview and app mode).
* @param popoverTrigger The button that opens the widget's actions popover.
* @param newName The new name to assign.
*/
async renameWidget(popoverTrigger: Locator, newName: string) {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })
await this.comfyPage.nextFrame()
}
/**
* Click a widget on the canvas to select it as a builder input.
* @param nodeTitle The displayed title of the node.
* @param widgetName The widget name to click.
*/
async selectInputWidget(nodeTitle: string, widgetName: string) {
await this.comfyPage.canvasOps.setScale(1)
const nodeRef = (
await this.comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
)[0]
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
await nodeRef.centerOnNode()
const widgetLocator = this.comfyPage.vueNodes
.getNodeLocator(String(nodeRef.id))
.getByLabel(widgetName, { exact: true })
await widgetLocator.click({ force: true })
await this.comfyPage.nextFrame()
}
/**
* Get the subtitle locator for a builder IoItem by its title text.
* Useful for asserting "Widget not visible" on disconnected inputs.
*/
getInputItemSubtitle(title: string): Locator {
return this.inputItems
.filter({
has: this.inputItemTitles.getByText(title, { exact: true })
})
.getByTestId(TestIds.builder.ioItemSubtitle)
}
/**
* Drag an IoItem from one index to another in the inputs step.
* Items are identified by their 0-based position among visible IoItems.
*/
async dragInputItem(fromIndex: number, toIndex: number) {
await dragByIndex(this.inputItems, fromIndex, toIndex)
await this.comfyPage.nextFrame()
}
/**
* Drag a widget item from one index to another in the preview/arrange step.
*/
async dragPreviewItem(fromIndex: number, toIndex: number) {
const items = this.page.getByTestId(TestIds.builder.widgetItem)
await dragByIndex(items, fromIndex, toIndex)
await this.comfyPage.nextFrame()
}
/**
* Click an output node on the canvas to select it as a builder output.
* @param nodeTitle The displayed title of the output node.
*/
async selectOutputNode(nodeTitle: string) {
await this.comfyPage.canvasOps.setScale(1)
const nodeRef = (
await this.comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
)[0]
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
await nodeRef.centerOnNode()
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
String(nodeRef.id)
)
await nodeLocator.click({ force: true })
await this.comfyPage.nextFrame()
}
}