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:
31
browser_tests/fixtures/components/BaseDialog.ts
Normal file
31
browser_tests/fixtures/components/BaseDialog.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class BaseDialog {
|
||||
readonly root: Locator
|
||||
readonly closeButton: Locator
|
||||
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
testId?: string
|
||||
) {
|
||||
this.root = testId ? page.getByTestId(testId) : page.locator('.p-dialog')
|
||||
this.closeButton = this.root.getByRole('button', { name: 'Close' })
|
||||
}
|
||||
|
||||
async isVisible(): Promise<boolean> {
|
||||
return this.root.isVisible()
|
||||
}
|
||||
|
||||
async waitForVisible(): Promise<void> {
|
||||
await this.root.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async waitForHidden(): Promise<void> {
|
||||
await this.root.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.closeButton.click({ force: true })
|
||||
await this.waitForHidden()
|
||||
}
|
||||
}
|
||||
35
browser_tests/fixtures/components/BottomPanel.ts
Normal file
35
browser_tests/fixtures/components/BottomPanel.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
class ShortcutsTab {
|
||||
readonly essentialsTab: Locator
|
||||
readonly viewControlsTab: Locator
|
||||
readonly manageButton: Locator
|
||||
readonly keyBadges: Locator
|
||||
readonly subcategoryTitles: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.essentialsTab = page.getByRole('tab', { name: /Essential/i })
|
||||
this.viewControlsTab = page.getByRole('tab', { name: /View Controls/i })
|
||||
this.manageButton = page.getByRole('button', { name: /Manage Shortcuts/i })
|
||||
this.keyBadges = page.locator('.key-badge')
|
||||
this.subcategoryTitles = page.locator('.subcategory-title')
|
||||
}
|
||||
}
|
||||
|
||||
export class BottomPanel {
|
||||
readonly root: Locator
|
||||
readonly keyboardShortcutsButton: Locator
|
||||
readonly toggleButton: Locator
|
||||
readonly shortcuts: ShortcutsTab
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.locator('.bottom-panel')
|
||||
this.keyboardShortcutsButton = page.getByRole('button', {
|
||||
name: /Keyboard Shortcuts/i
|
||||
})
|
||||
this.toggleButton = page.getByRole('button', {
|
||||
name: /Toggle Bottom Panel/i
|
||||
})
|
||||
this.shortcuts = new ShortcutsTab(page)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export class ComfyNodeSearchFilterSelectionPanel {
|
||||
async addFilter(filterValue: string, filterType: string) {
|
||||
await this.selectFilterType(filterType)
|
||||
await this.selectFilterValue(filterValue)
|
||||
await this.page.locator('button:has-text("Add")').click()
|
||||
await this.page.getByRole('button', { name: 'Add', exact: true }).click()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
54
browser_tests/fixtures/components/ContextMenu.ts
Normal file
54
browser_tests/fixtures/components/ContextMenu.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ContextMenu {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
get primeVueMenu() {
|
||||
return this.page.locator('.p-contextmenu, .p-menu')
|
||||
}
|
||||
|
||||
get litegraphMenu() {
|
||||
return this.page.locator('.litemenu')
|
||||
}
|
||||
|
||||
get menuItems() {
|
||||
return this.page.locator('.p-menuitem, .litemenu-entry')
|
||||
}
|
||||
|
||||
async clickMenuItem(name: string): Promise<void> {
|
||||
await this.page.getByRole('menuitem', { name }).click()
|
||||
}
|
||||
|
||||
async clickLitegraphMenuItem(name: string): Promise<void> {
|
||||
await this.page.locator(`.litemenu-entry:has-text("${name}")`).click()
|
||||
}
|
||||
|
||||
async isVisible(): Promise<boolean> {
|
||||
const primeVueVisible = await this.primeVueMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const litegraphVisible = await this.litegraphMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
return primeVueVisible || litegraphVisible
|
||||
}
|
||||
|
||||
async waitForHidden(): Promise<void> {
|
||||
const waitIfExists = async (locator: Locator, menuName: string) => {
|
||||
const count = await locator.count()
|
||||
if (count > 0) {
|
||||
await locator.waitFor({ state: 'hidden' }).catch((error: Error) => {
|
||||
console.warn(
|
||||
`[waitForHidden] ${menuName} waitFor failed:`,
|
||||
error.message
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
waitIfExists(this.primeVueMenu, 'primeVueMenu'),
|
||||
waitIfExists(this.litegraphMenu, 'litegraphMenu')
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
import { BaseDialog } from './BaseDialog'
|
||||
|
||||
export class SettingDialog {
|
||||
export class SettingDialog extends BaseDialog {
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
page: Page,
|
||||
public readonly comfyPage: ComfyPage
|
||||
) {}
|
||||
|
||||
get root() {
|
||||
return this.page.locator('div.settings-container')
|
||||
) {
|
||||
super(page, TestIds.dialogs.settings)
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this.comfyPage.executeCommand('Comfy.ShowSettingsDialog')
|
||||
await this.page.waitForSelector('div.settings-container')
|
||||
await this.comfyPage.command.executeCommand('Comfy.ShowSettingsDialog')
|
||||
await this.waitForVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,8 +41,9 @@ export class SettingDialog {
|
||||
}
|
||||
|
||||
async goToAboutPanel() {
|
||||
const aboutButton = this.page.locator('li[aria-label="About"]')
|
||||
await aboutButton.click()
|
||||
await this.page.waitForSelector('div.about-container')
|
||||
await this.page.getByTestId(TestIds.dialogs.settingsTabAbout).click()
|
||||
await this.page
|
||||
.getByTestId(TestIds.dialogs.about)
|
||||
.waitFor({ state: 'visible' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
class SidebarTab {
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
@@ -31,16 +34,16 @@ class SidebarTab {
|
||||
}
|
||||
|
||||
export class NodeLibrarySidebarTab extends SidebarTab {
|
||||
constructor(public readonly page: Page) {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
}
|
||||
|
||||
get nodeLibrarySearchBoxInput() {
|
||||
return this.page.locator('.node-lib-search-box input[type="text"]')
|
||||
return this.page.getByPlaceholder('Search Nodes...')
|
||||
}
|
||||
|
||||
get nodeLibraryTree() {
|
||||
return this.page.locator('.node-lib-tree-explorer')
|
||||
return this.page.getByTestId(TestIds.sidebar.nodeLibrary)
|
||||
}
|
||||
|
||||
get nodePreview() {
|
||||
@@ -55,12 +58,12 @@ export class NodeLibrarySidebarTab extends SidebarTab {
|
||||
return this.tabContainer.locator('.new-folder-button')
|
||||
}
|
||||
|
||||
async open() {
|
||||
override async open() {
|
||||
await super.open()
|
||||
await this.nodeLibraryTree.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async close() {
|
||||
override async close() {
|
||||
if (!this.tabButton.isVisible()) {
|
||||
return
|
||||
}
|
||||
@@ -69,30 +72,40 @@ export class NodeLibrarySidebarTab extends SidebarTab {
|
||||
await this.nodeLibraryTree.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
folderSelector(folderName: string) {
|
||||
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-folder .node-label:has-text("${folderName}")))`
|
||||
}
|
||||
|
||||
getFolder(folderName: string) {
|
||||
return this.page.locator(this.folderSelector(folderName))
|
||||
}
|
||||
|
||||
nodeSelector(nodeName: string) {
|
||||
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-leaf .node-label:has-text("${nodeName}")))`
|
||||
return this.page.locator(
|
||||
`[data-testid="node-tree-folder"][data-folder-name="${folderName}"]`
|
||||
)
|
||||
}
|
||||
|
||||
getNode(nodeName: string) {
|
||||
return this.page.locator(this.nodeSelector(nodeName))
|
||||
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 WorkflowsSidebarTab extends SidebarTab {
|
||||
constructor(public readonly page: Page) {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'workflows')
|
||||
}
|
||||
|
||||
get root() {
|
||||
return this.page.locator('.workflows-sidebar-tab')
|
||||
return this.page.getByTestId(TestIds.sidebar.workflows)
|
||||
}
|
||||
|
||||
async getOpenedWorkflowNames() {
|
||||
@@ -140,7 +153,9 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
|
||||
// Wait for workflow service to finish renaming
|
||||
await this.page.waitForFunction(
|
||||
() => !window['app']?.extensionManager?.workflow?.isBusy,
|
||||
() =>
|
||||
!(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
|
||||
?.isBusy,
|
||||
undefined,
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
|
||||
export class Topbar {
|
||||
private readonly menuLocator: Locator
|
||||
@@ -57,7 +58,7 @@ export class Topbar {
|
||||
|
||||
async closeWorkflowTab(tabName: string) {
|
||||
const tab = this.getWorkflowTab(tabName)
|
||||
await tab.locator('.close-button').click({ force: true })
|
||||
await tab.getByRole('button', { name: 'Close' }).click({ force: true })
|
||||
}
|
||||
|
||||
getSaveDialog(): Locator {
|
||||
@@ -86,7 +87,7 @@ export class Topbar {
|
||||
|
||||
// Wait for workflow service to finish saving
|
||||
await this.page.waitForFunction(
|
||||
() => !window['app'].extensionManager.workflow.isBusy,
|
||||
() => !(window.app!.extensionManager as WorkspaceStore).workflow.isBusy,
|
||||
undefined,
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
@@ -122,7 +123,7 @@ export class Topbar {
|
||||
*/
|
||||
async closeTopbarMenu() {
|
||||
await this.page.locator('body').click({ position: { x: 300, y: 10 } })
|
||||
await expect(this.menuLocator).not.toBeVisible()
|
||||
await this.menuLocator.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user