Files
ComfyUI_frontend/browser_tests/fixtures/components/SidebarTab.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

398 lines
12 KiB
TypeScript

import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { TestIds } from '@e2e/fixtures/selectors'
class SidebarTab {
public readonly tabButton: Locator
public readonly selectedTabButton: Locator
constructor(
public readonly page: Page,
public readonly tabId: string
) {
this.tabButton = page.locator(`.${tabId}-tab-button`)
this.selectedTabButton = page.locator(
`.${tabId}-tab-button.side-bar-button-selected`
)
}
async open() {
if (await this.selectedTabButton.isVisible()) {
return
}
await this.tabButton.click()
}
async close() {
if (!this.tabButton.isVisible()) {
return
}
await this.tabButton.click()
}
}
export class NodeLibrarySidebarTab extends SidebarTab {
public readonly nodeLibrarySearchBoxInput: Locator
public readonly nodeLibraryTree: Locator
public readonly nodePreview: Locator
public readonly tabContainer: Locator
public readonly newFolderButton: Locator
constructor(public override readonly page: Page) {
super(page, 'node-library')
this.nodeLibrarySearchBoxInput = page.getByPlaceholder('Search Nodes...')
this.nodeLibraryTree = page.getByTestId(TestIds.sidebar.nodeLibrary)
this.nodePreview = page.locator('.node-lib-node-preview')
this.tabContainer = page.locator('.sidebar-content-container')
this.newFolderButton = this.tabContainer.locator('.new-folder-button')
}
override async open() {
await super.open()
await this.nodeLibraryTree.waitFor({ state: 'visible' })
}
override async close() {
if (!this.tabButton.isVisible()) {
return
}
await this.tabButton.click()
await this.nodeLibraryTree.waitFor({ state: 'hidden' })
}
getFolder(folderName: string) {
return this.page.locator(
`[data-testid="node-tree-folder"][data-folder-name="${folderName}"]`
)
}
getNode(nodeName: string) {
return this.page.locator(
`[data-testid="node-tree-leaf"][data-node-name="${nodeName}"]`
)
}
nodeSelector(nodeName: string): string {
return `[data-testid="node-tree-leaf"][data-node-name="${nodeName}"]`
}
folderSelector(folderName: string): string {
return `[data-testid="node-tree-folder"][data-folder-name="${folderName}"]`
}
getNodeInFolder(nodeName: string, folderName: string) {
return this.getFolder(folderName)
.locator('xpath=ancestor::li')
.locator(`[data-testid="node-tree-leaf"][data-node-name="${nodeName}"]`)
}
}
export class NodeLibrarySidebarTabV2 extends SidebarTab {
public readonly searchInput: Locator
public readonly sidebarContent: Locator
public readonly allTab: Locator
public readonly blueprintsTab: Locator
public readonly sortButton: Locator
constructor(public override readonly page: Page) {
super(page, 'node-library')
this.searchInput = page.getByPlaceholder('Search...')
this.sidebarContent = page.locator('.sidebar-content-container')
this.allTab = this.getTab('All')
this.blueprintsTab = this.getTab('Blueprints')
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
}
getTab(name: string) {
return this.sidebarContent.getByRole('tab', { name, exact: true })
}
getFolder(folderName: string) {
return this.sidebarContent
.getByRole('treeitem', { name: folderName })
.first()
}
getNode(nodeName: string) {
return this.sidebarContent.getByRole('treeitem', { name: nodeName }).first()
}
async expandFolder(folderName: string) {
const folder = this.getFolder(folderName)
const isExpanded = await folder.getAttribute('aria-expanded')
if (isExpanded !== 'true') {
await folder.click()
}
}
override async open() {
await super.open()
await this.searchInput.waitFor({ state: 'visible' })
}
}
export class WorkflowsSidebarTab extends SidebarTab {
public readonly root: Locator
public readonly activeWorkflowLabel: Locator
constructor(public override readonly page: Page) {
super(page, 'workflows')
this.root = page.getByTestId(TestIds.sidebar.workflows)
this.activeWorkflowLabel = this.root.locator(
'.comfyui-workflows-open .p-tree-node-selected .node-label'
)
}
async getOpenedWorkflowNames() {
return await this.root
.locator('.comfyui-workflows-open .node-label')
.allInnerTexts()
}
async getActiveWorkflowName() {
return await this.activeWorkflowLabel.innerText()
}
async getTopLevelSavedWorkflowNames() {
return await this.root
.locator('.comfyui-workflows-browse .node-label')
.allInnerTexts()
}
async switchToWorkflow(workflowName: string) {
const workflowLocator = this.getOpenedItem(workflowName)
await workflowLocator.click()
}
getOpenedItem(name: string) {
return this.root.locator('.comfyui-workflows-open .node-label', {
hasText: name
})
}
getPersistedItem(name: string) {
return this.root.locator('.comfyui-workflows-browse .node-label', {
hasText: name
})
}
async renameWorkflow(locator: Locator, newName: string) {
await locator.click({ button: 'right' })
await this.page
.locator('.p-contextmenu-item-content', { hasText: 'Rename' })
.click()
await this.page.keyboard.type(newName)
await this.page.keyboard.press('Enter')
// Wait for workflow service to finish renaming
await this.page.waitForFunction(
() =>
!(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy,
undefined,
{ timeout: 3000 }
)
}
async insertWorkflow(locator: Locator) {
await locator.click({ button: 'right' })
await this.page
.locator('.p-contextmenu-item-content', { hasText: 'Insert' })
.click()
}
}
export class ModelLibrarySidebarTab extends SidebarTab {
public readonly searchInput: Locator
public readonly modelTree: Locator
public readonly refreshButton: Locator
public readonly loadAllFoldersButton: Locator
public readonly folderNodes: Locator
public readonly leafNodes: Locator
public readonly modelPreview: Locator
constructor(public override readonly page: Page) {
super(page, 'model-library')
this.searchInput = page.getByPlaceholder('Search Models...')
this.modelTree = page.locator('.model-lib-tree-explorer')
this.refreshButton = page.getByRole('button', { name: 'Refresh' })
this.loadAllFoldersButton = page.getByRole('button', {
name: 'Load All Folders'
})
this.folderNodes = this.modelTree.locator(
'.p-tree-node:not(.p-tree-node-leaf)'
)
this.leafNodes = this.modelTree.locator('.p-tree-node-leaf')
this.modelPreview = page.locator('.model-lib-model-preview')
}
override async open() {
await super.open()
await this.modelTree.waitFor({ state: 'visible' })
}
getFolderByLabel(label: string) {
return this.modelTree
.locator('.p-tree-node:not(.p-tree-node-leaf)')
.filter({ hasText: label })
.first()
}
getLeafByLabel(label: string) {
return this.modelTree
.locator('.p-tree-node-leaf')
.filter({ hasText: label })
.first()
}
}
export class AssetsSidebarTab extends SidebarTab {
// --- Tab navigation ---
public readonly generatedTab: Locator
public readonly importedTab: Locator
// --- Empty state ---
public readonly emptyStateMessage: Locator
// --- Search & filter ---
public readonly searchInput: Locator
public readonly settingsButton: Locator
// --- View mode ---
public readonly listViewOption: Locator
public readonly gridViewOption: Locator
// --- Sort options (cloud-only, shown inside settings popover) ---
public readonly sortNewestFirst: Locator
public readonly sortOldestFirst: Locator
// --- Asset cards ---
public readonly assetCards: Locator
public readonly selectedCards: Locator
// --- List view items ---
public readonly listViewItems: Locator
// --- Selection footer ---
public readonly selectionFooter: Locator
public readonly selectionCountButton: Locator
public readonly deselectAllButton: Locator
public readonly deleteSelectedButton: Locator
public readonly downloadSelectedButton: Locator
// --- Folder view ---
public readonly backToAssetsButton: Locator
// --- Loading ---
public readonly skeletonLoaders: Locator
constructor(public override readonly page: Page) {
super(page, 'assets')
this.generatedTab = page.getByRole('tab', { name: 'Generated' })
this.importedTab = page.getByRole('tab', { name: 'Imported' })
this.emptyStateMessage = page.getByText(
'Upload files or generate content to see them here'
)
this.searchInput = page.getByPlaceholder('Search Assets...')
this.settingsButton = page.getByRole('button', { name: 'View settings' })
this.listViewOption = page.getByText('List view')
this.gridViewOption = page.getByText('Grid view')
this.sortNewestFirst = page.getByText('Newest first')
this.sortOldestFirst = page.getByText('Oldest first')
this.assetCards = page.locator('[role="button"][data-selected]')
this.selectedCards = page.locator('[data-selected="true"]')
this.listViewItems = page.locator(
'.sidebar-content-container [role="button"][tabindex="0"]'
)
this.selectionFooter = page
.locator('.sidebar-content-container')
.locator('..')
.locator('[class*="h-18"]')
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
this.deselectAllButton = page.getByText('Deselect all')
this.deleteSelectedButton = page
.getByTestId('assets-delete-selected')
.or(page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
.first()
this.downloadSelectedButton = page
.getByTestId('assets-download-selected')
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
.first()
this.backToAssetsButton = page.getByText('Back to all assets')
this.skeletonLoaders = page.locator(
'.sidebar-content-container .animate-pulse'
)
}
emptyStateTitle(title: string) {
return this.page.getByText(title)
}
getAssetCardByName(name: string) {
return this.assetCards.filter({ hasText: name })
}
contextMenuItem(label: string) {
return this.page.locator('.p-contextmenu').getByText(label)
}
override async open() {
// Remove any toast notifications that may overlay the sidebar button
await this.dismissToasts()
await super.open()
await this.generatedTab.waitFor({ state: 'visible' })
}
/** Dismiss all visible toast notifications by clicking their close buttons. */
async dismissToasts() {
const closeButtons = this.page.locator('.p-toast-close-button')
for (const btn of await closeButtons.all()) {
await btn.click({ force: true }).catch(() => {})
}
// Wait for all toast elements to fully animate out and detach from DOM
await expect(this.page.locator('.p-toast-message'))
.toHaveCount(0)
.catch(() => {})
}
async switchToImported() {
await this.dismissToasts()
await this.importedTab.click()
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true')
}
async switchToGenerated() {
await this.dismissToasts()
await this.generatedTab.click()
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true')
}
async openSettingsMenu() {
await this.dismissToasts()
await this.settingsButton.click()
// Wait for popover content to render
await this.listViewOption
.or(this.gridViewOption)
.first()
.waitFor({ state: 'visible', timeout: 3000 })
}
async rightClickAsset(name: string) {
const card = this.getAssetCardByName(name)
await card.click({ button: 'right' })
await this.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
}
async waitForAssets(count?: number) {
if (count !== undefined) {
await expect(this.assetCards).toHaveCount(count)
} else {
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
}
}
}