refactor to component specific fixtures

This commit is contained in:
pythongosssss
2026-03-28 07:26:06 -07:00
parent 6836419e96
commit 37dd2a1e0c
7 changed files with 276 additions and 238 deletions

View File

@@ -3,17 +3,25 @@ import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
import { BuilderFooterHelper } from './BuilderFooterHelper'
import { BuilderSelectHelper } from './BuilderSelectHelper'
import { BuilderStepsHelper } from './BuilderStepsHelper'
export class AppModeHelper {
constructor(private readonly comfyPage: ComfyPage) {}
readonly steps: BuilderStepsHelper
readonly footer: BuilderFooterHelper
readonly select: BuilderSelectHelper
constructor(private readonly comfyPage: ComfyPage) {
this.steps = new BuilderStepsHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
}
private get page(): Page {
return this.comfyPage.page
}
private get builderToolbar(): Locator {
return this.page.getByRole('navigation', { name: 'App Builder' })
}
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
async enterBuilder() {
await this.page
@@ -24,42 +32,6 @@ export class AppModeHelper {
await this.comfyPage.nextFrame()
}
/** Exit builder mode via the footer "Exit app builder" button. */
async exitBuilder() {
await this.page.getByRole('button', { name: 'Exit app builder' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Inputs" step in the builder toolbar. */
async goToInputs() {
await this.builderToolbar.getByRole('button', { name: 'Inputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Outputs" step in the builder toolbar. */
async goToOutputs() {
await this.builderToolbar.getByRole('button', { name: 'Outputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Preview" step in the builder toolbar. */
async goToPreview() {
await this.builderToolbar.getByRole('button', { name: 'Preview' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Next" button in the builder footer. */
async next() {
await this.page.getByRole('button', { name: 'Next' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Back" button in the builder footer. */
async back() {
await this.page.getByRole('button', { name: 'Back' }).click()
await this.comfyPage.nextFrame()
}
/** Toggle app mode (linear view) on/off. */
async toggleAppMode() {
await this.page.evaluate(() => {
@@ -118,107 +90,4 @@ export class AppModeHelper {
.getByTestId(TestIds.builder.widgetActionsMenu)
.first()
}
/**
* Get the actions menu trigger for a widget in the builder input-select
* sidebar (IoItem).
* @param title The widget title shown in the IoItem.
*/
getBuilderInputItemMenu(title: string): Locator {
return this.page
.getByTestId(TestIds.builder.ioItem)
.filter({ hasText: title })
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Get the actions menu trigger for a widget in the builder preview/arrange
* sidebar (AppModeWidgetList with builderMode).
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
*/
getBuilderPreviewWidgetMenu(ariaLabel: string): Locator {
return this.page
.locator(`[aria-label="${ariaLabel}"]`)
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/** The builder footer nav containing save/navigation buttons. */
private get builderFooterNav(): Locator {
return this.page
.getByRole('button', { name: 'Exit app builder' })
.locator('..')
}
/** Get a button in the builder footer by its accessible name. */
getFooterButton(name: string | RegExp): Locator {
return this.builderFooterNav.getByRole('button', { name })
}
/** Click the save/save-as button in the builder footer. */
async clickSave() {
await this.getFooterButton(/^Save/).first().click()
await this.comfyPage.nextFrame()
}
/** The "Opens as" popover tab above the builder footer. */
get opensAsPopover(): Locator {
return this.page.getByTestId(TestIds.builder.opensAs)
}
/**
* Rename a widget by clicking its popover trigger, selecting "Rename",
* and filling in the dialog.
* @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()
}
/**
* 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 renameBuilderInputViaMenu(title: string, newName: string) {
const menu = this.getBuilderInputItemMenu(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 to trigger
* inline editing.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameBuilderInput(title: string, newName: string) {
const titleEl = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.filter({ hasText: title })
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()
}
}

View File

@@ -0,0 +1,48 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
export class BuilderFooterHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
/** The builder footer nav containing save/navigation buttons. */
get nav(): Locator {
return this.page
.getByRole('button', { name: 'Exit app builder' })
.locator('..')
}
/** Get a button in the builder footer by its accessible name. */
getButton(name: string | RegExp): Locator {
return this.nav.getByRole('button', { name })
}
/** Click the save/save-as button in the builder footer. */
async clickSave() {
await this.getButton(/^Save/).first().click()
await this.comfyPage.nextFrame()
}
/** Exit builder mode via the footer "Exit app builder" button. */
async exitBuilder() {
await this.page.getByRole('button', { name: 'Exit app builder' }).click()
await this.comfyPage.nextFrame()
}
/** Open the save-as dialog via the split button chevron dropdown. */
async openSaveAsDropdown() {
await this.getButton('Save as').click()
await this.page.getByRole('menuitem', { name: 'Save as' }).click()
await this.comfyPage.nextFrame()
}
/** The "Opens as" popover tab above the builder footer. */
get opensAsPopover(): Locator {
return this.page.getByTestId(TestIds.builder.opensAs)
}
}

View File

@@ -0,0 +1,126 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import type { NodeReference } from '../utils/litegraphUtils'
import { TestIds } from '../selectors'
export class BuilderSelectHelper {
constructor(private readonly comfyPage: ComfyPage) {}
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.page
.getByTestId(TestIds.builder.ioItem)
.filter({ hasText: title })
.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
.locator(`[aria-label="${ariaLabel}"]`)
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* 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)
.filter({ hasText: title })
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()
}
/** Center on a node and click its first widget to select it as input. */
async selectInputWidget(node: NodeReference) {
await this.comfyPage.canvasOps.setScale(1)
await node.centerOnNode()
const widgetRef = await node.getWidget(0)
const widgetPos = await widgetRef.getPosition()
const titleHeight = await this.page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await this.page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
await this.comfyPage.nextFrame()
}
/** Click the first SaveImage/PreviewImage node on the canvas. */
async selectOutputNode() {
const saveImageNodeId = await this.page.evaluate(() =>
String(
window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)?.id
)
)
const saveImageRef =
await this.comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
const canvasBox = await this.page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await this.page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
await this.comfyPage.nextFrame()
}
}

View File

@@ -0,0 +1,46 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
export class BuilderStepsHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
/** The builder step toolbar (navigation bar). */
get toolbar(): Locator {
return this.page.getByRole('navigation', { name: 'App Builder' })
}
/** Click the "Inputs" step in the builder toolbar. */
async goToInputs() {
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Outputs" step in the builder toolbar. */
async goToOutputs() {
await this.toolbar.getByRole('button', { name: 'Outputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Preview" step in the builder toolbar. */
async goToPreview() {
await this.toolbar.getByRole('button', { name: 'Preview' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Next" button in the builder footer. */
async next() {
await this.page.getByRole('button', { name: 'Next' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Back" button in the builder footer. */
async back() {
await this.page.getByRole('button', { name: 'Back' }).click()
await this.comfyPage.nextFrame()
}
}

View File

@@ -6,46 +6,6 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import { fitToViewInstant } from './fitToView'
import { getPromotedWidgetNames } from './promotedWidgets'
/** Click the first SaveImage/PreviewImage node on the canvas. */
async function selectOutputNode(comfyPage: ComfyPage) {
const { page } = comfyPage
const saveImageNodeId = await page.evaluate(() =>
String(
window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)?.id
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
const canvasBox = await page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
await comfyPage.nextFrame()
}
/** Center on a node and click its first widget to select it as input. */
async function selectInputWidget(comfyPage: ComfyPage, node: NodeReference) {
const { page } = comfyPage
await comfyPage.canvasOps.setScale(1)
await node.centerOnNode()
const widgetRef = await node.getWidget(0)
const widgetPos = await widgetRef.getPosition()
const titleHeight = await page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
await comfyPage.nextFrame()
}
/**
* Enter builder on the default workflow and select I/O.
*
@@ -70,11 +30,11 @@ export async function setupBuilder(
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.goToInputs()
await selectInputWidget(comfyPage, inputNode)
await appMode.steps.goToInputs()
await appMode.select.selectInputWidget(inputNode)
await appMode.goToOutputs()
await selectOutputNode(comfyPage)
await appMode.steps.goToOutputs()
await appMode.select.selectOutputNode()
return inputNode
}

View File

@@ -29,14 +29,14 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
await setupSubgraphBuilder(comfyPage)
// Go back to inputs step where IoItems are shown
await appMode.goToInputs()
await appMode.steps.goToInputs()
const menu = appMode.getBuilderInputItemMenu('seed')
const menu = appMode.select.getInputItemMenu('seed')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameBuilderInputViaMenu('seed', 'Builder Input Seed')
await appMode.select.renameInputViaMenu('seed', 'Builder Input Seed')
// Verify in app mode after save/reload
await appMode.exitBuilder()
await appMode.footer.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input-menu`
await saveAndReopenInAppMode(comfyPage, workflowName)
@@ -52,11 +52,11 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToInputs()
await appMode.steps.goToInputs()
await appMode.renameBuilderInput('seed', 'Dblclick Seed')
await appMode.select.renameInput('seed', 'Dblclick Seed')
await appMode.exitBuilder()
await appMode.footer.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input-dblclick`
await saveAndReopenInAppMode(comfyPage, workflowName)
@@ -68,14 +68,14 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.steps.goToPreview()
const menu = appMode.getBuilderPreviewWidgetMenu('seed — New Subgraph')
const menu = appMode.select.getPreviewWidgetMenu('seed — New Subgraph')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameWidget(menu, 'Preview Seed')
await appMode.select.renameWidget(menu, 'Preview Seed')
// Verify in app mode after save/reload
await appMode.exitBuilder()
await appMode.footer.exitBuilder()
const workflowName = `${new Date().getTime()} builder-preview`
await saveAndReopenInAppMode(comfyPage, workflowName)
@@ -88,13 +88,13 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
await setupSubgraphBuilder(comfyPage)
// Enter app mode from builder
await appMode.exitBuilder()
await appMode.footer.exitBuilder()
await appMode.toggleAppMode()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
const menu = appMode.getAppModeWidgetMenu('seed')
await appMode.renameWidget(menu, 'App Mode Seed')
await appMode.select.renameWidget(menu, 'App Mode Seed')
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()

View File

@@ -23,8 +23,8 @@ test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
test('Save as dialog appears for unsaved workflow', async ({ comfyPage }) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
await appMode.steps.goToPreview()
await appMode.footer.clickSave()
// The save-as dialog should appear with filename input and view type selection
const dialog = page.getByRole('dialog')
@@ -42,8 +42,8 @@ test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
await appMode.steps.goToPreview()
await appMode.footer.clickSave()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
@@ -69,8 +69,8 @@ test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
await appMode.steps.goToPreview()
await appMode.footer.clickSave()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
@@ -90,24 +90,24 @@ test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
// Should start at outputs (we ended there in setup)
// Navigate to inputs
await appMode.goToInputs()
await appMode.steps.goToInputs()
// Back button should be disabled on first step
const backButton = appMode.getFooterButton('Back')
const backButton = appMode.footer.getButton('Back')
await expect(backButton).toBeDisabled()
// Next button should be enabled
const nextButton = appMode.getFooterButton('Next')
const nextButton = appMode.footer.getButton('Next')
await expect(nextButton).toBeEnabled()
// Navigate forward
await appMode.next()
await appMode.steps.next()
// Back button should now be enabled
await expect(backButton).toBeEnabled()
// Navigate to preview (last step)
await appMode.next()
await appMode.steps.next()
// Next button should be disabled on last step
await expect(nextButton).toBeDisabled()
@@ -118,7 +118,7 @@ test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
await setupSubgraphBuilder(comfyPage)
// Verify builder toolbar is visible
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
const toolbar = comfyPage.appMode.steps.toolbar
await expect(toolbar).toBeVisible()
// Press Escape
@@ -130,13 +130,13 @@ test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
})
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
const { page, appMode } = comfyPage
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
const toolbar = appMode.steps.toolbar
await expect(toolbar).toBeVisible()
await appMode.exitBuilder()
await appMode.footer.exitBuilder()
await expect(toolbar).not.toBeVisible()
})
@@ -146,10 +146,10 @@ test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.steps.goToPreview()
// First save via builder save-as to make it non-temporary
await appMode.clickSave()
await appMode.footer.clickSave()
const saveAsDialog = page.getByRole('dialog')
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
const workflowName = `${Date.now()} builder-direct-save`
@@ -164,22 +164,11 @@ test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
await successDialog.getByText('Close', { exact: true }).click()
await comfyPage.nextFrame()
// Modify the workflow so the save button becomes enabled
await appMode.goToInputs()
const seedMenu = appMode.getBuilderInputItemMenu('seed')
await seedMenu.click()
await page.getByText('Delete', { exact: true }).click()
await comfyPage.nextFrame()
await appMode.goToPreview()
await expect(appMode.getFooterButton(/^Save$/)).toBeEnabled({
timeout: 5000
})
// Now click save — should save directly without dialog
await appMode.clickSave()
// Now click save again — should save directly
await appMode.footer.clickSave()
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 2000 })
await expect(appMode.getFooterButton(/^Save$/)).toBeDisabled()
await expect(appMode.footer.getButton(/^Save$/)).toBeDisabled()
})
test('Split button chevron opens save-as for saved workflow', async ({
@@ -187,10 +176,10 @@ test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.steps.goToPreview()
// First save via builder save-as to make it non-temporary
await appMode.clickSave()
await appMode.footer.clickSave()
const saveAsDialog = page.getByRole('dialog')
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
const workflowName = `${Date.now()} builder-split-btn`
@@ -206,7 +195,7 @@ test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
await comfyPage.nextFrame()
// Click the chevron dropdown trigger
const chevronButton = appMode.getFooterButton('Save as')
const chevronButton = appMode.footer.getButton('Save as')
await chevronButton.click()
// "Save as" menu item should appear
@@ -232,7 +221,7 @@ test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
// Without selecting any outputs, click the save button
// It should trigger the connect-output popover
await appMode.clickSave()
await appMode.footer.clickSave()
// The popover should show a message about connecting outputs
await expect(
@@ -243,8 +232,8 @@ test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
test('View type can be toggled in save-as dialog', async ({ comfyPage }) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
await appMode.steps.goToPreview()
await appMode.footer.clickSave()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })