mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
test/refactor: App mode - Refactor & Save As tests (#10680)
## Summary Adds e2e Save As tests for #10679. Refactors tests to remove getByX and other locators in tests to instead be in fixtures. ## Changes - **What**: - extract app mode fixtures - add test ids where required - add new tests ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10680-test-refactor-App-mode-Refactor-Save-As-tests-3316d73d3650815b9d49ccfbb6d54416) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -3,17 +3,28 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
import { BuilderFooterHelper } from './BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from './BuilderSaveAsHelper'
|
||||
import { BuilderSelectHelper } from './BuilderSelectHelper'
|
||||
import { BuilderStepsHelper } from './BuilderStepsHelper'
|
||||
|
||||
export class AppModeHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly footer: BuilderFooterHelper
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.footer = new BuilderFooterHelper(comfyPage)
|
||||
this.saveAs = new BuilderSaveAsHelper(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 +35,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 +93,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()
|
||||
}
|
||||
}
|
||||
|
||||
69
browser_tests/fixtures/helpers/BuilderFooterHelper.ts
Normal file
69
browser_tests/fixtures/helpers/BuilderFooterHelper.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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
|
||||
}
|
||||
|
||||
get nav(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.footerNav)
|
||||
}
|
||||
|
||||
get exitButton(): Locator {
|
||||
return this.buttonByName('Exit app builder')
|
||||
}
|
||||
|
||||
get nextButton(): Locator {
|
||||
return this.buttonByName('Next')
|
||||
}
|
||||
|
||||
get backButton(): Locator {
|
||||
return this.buttonByName('Back')
|
||||
}
|
||||
|
||||
get saveButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveButton)
|
||||
}
|
||||
|
||||
get saveAsButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveAsButton)
|
||||
}
|
||||
|
||||
get saveAsChevron(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveAsChevron)
|
||||
}
|
||||
|
||||
get opensAsPopover(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.opensAs)
|
||||
}
|
||||
|
||||
private buttonByName(name: string): Locator {
|
||||
return this.nav.getByRole('button', { name })
|
||||
}
|
||||
|
||||
async next() {
|
||||
await this.nextButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async back() {
|
||||
await this.backButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async exitBuilder() {
|
||||
await this.exitButton.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async openSaveAsFromChevron() {
|
||||
await this.saveAsChevron.click()
|
||||
await this.page.getByRole('menuitem', { name: 'Save as' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
78
browser_tests/fixtures/helpers/BuilderSaveAsHelper.ts
Normal file
78
browser_tests/fixtures/helpers/BuilderSaveAsHelper.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
|
||||
export class BuilderSaveAsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
/** The save-as dialog (scoped by aria-labelledby). */
|
||||
get dialog(): Locator {
|
||||
return this.page.locator('[aria-labelledby="builder-save"]')
|
||||
}
|
||||
|
||||
/** The post-save success dialog (scoped by aria-labelledby). */
|
||||
get successDialog(): Locator {
|
||||
return this.page.locator('[aria-labelledby="builder-save-success"]')
|
||||
}
|
||||
|
||||
get title(): Locator {
|
||||
return this.dialog.getByText('Save as')
|
||||
}
|
||||
|
||||
get radioGroup(): Locator {
|
||||
return this.dialog.getByRole('radiogroup')
|
||||
}
|
||||
|
||||
get nameInput(): Locator {
|
||||
return this.dialog.getByRole('textbox')
|
||||
}
|
||||
|
||||
viewTypeRadio(viewType: 'App' | 'Node graph'): Locator {
|
||||
return this.dialog.getByRole('radio', { name: viewType })
|
||||
}
|
||||
|
||||
get saveButton(): Locator {
|
||||
return this.dialog.getByRole('button', { name: 'Save' })
|
||||
}
|
||||
|
||||
get successMessage(): Locator {
|
||||
return this.successDialog.getByText('Successfully saved')
|
||||
}
|
||||
|
||||
get viewAppButton(): Locator {
|
||||
return this.successDialog.getByRole('button', { name: 'View app' })
|
||||
}
|
||||
|
||||
get closeButton(): Locator {
|
||||
return this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
}
|
||||
|
||||
/** The X button to dismiss the success dialog without any action. */
|
||||
get dismissButton(): Locator {
|
||||
return this.successDialog.locator('button.p-dialog-close-button')
|
||||
}
|
||||
|
||||
get exitBuilderButton(): Locator {
|
||||
return this.successDialog.getByRole('button', { name: 'Exit builder' })
|
||||
}
|
||||
|
||||
get overwriteDialog(): Locator {
|
||||
return this.page.getByRole('dialog', { name: 'Overwrite existing file?' })
|
||||
}
|
||||
|
||||
get overwriteButton(): Locator {
|
||||
return this.overwriteDialog.getByRole('button', { name: 'Overwrite' })
|
||||
}
|
||||
|
||||
async fillAndSave(workflowName: string, viewType: 'App' | 'Node graph') {
|
||||
await this.nameInput.fill(workflowName)
|
||||
await this.viewTypeRadio(viewType).click()
|
||||
await this.saveButton.click()
|
||||
}
|
||||
}
|
||||
139
browser_tests/fixtures/helpers/BuilderSelectHelper.ts
Normal file
139
browser_tests/fixtures/helpers/BuilderSelectHelper.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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({
|
||||
has: this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.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()
|
||||
}
|
||||
|
||||
/** 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(() => {
|
||||
const node = window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
return node ? String(node.id) : null
|
||||
})
|
||||
if (!saveImageNodeId)
|
||||
throw new Error('SaveImage/PreviewImage node not found')
|
||||
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()
|
||||
}
|
||||
}
|
||||
30
browser_tests/fixtures/helpers/BuilderStepsHelper.ts
Normal file
30
browser_tests/fixtures/helpers/BuilderStepsHelper.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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
|
||||
}
|
||||
|
||||
get toolbar(): Locator {
|
||||
return this.page.getByRole('navigation', { name: 'App Builder' })
|
||||
}
|
||||
|
||||
async goToInputs() {
|
||||
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async goToOutputs() {
|
||||
await this.toolbar.getByRole('button', { name: 'Outputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async goToPreview() {
|
||||
await this.toolbar.getByRole('button', { name: 'Preview' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import type { AppMode } from '../../../src/composables/useAppMode'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
@@ -104,6 +105,40 @@ export class WorkflowHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async getActiveWorkflowPath(): Promise<string | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.path
|
||||
})
|
||||
}
|
||||
|
||||
async getActiveWorkflowActiveAppMode(): Promise<AppMode | null | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.activeMode
|
||||
})
|
||||
}
|
||||
|
||||
async getActiveWorkflowInitialMode(): Promise<AppMode | null | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.initialMode
|
||||
})
|
||||
}
|
||||
|
||||
async getLinearModeFromGraph(): Promise<boolean | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return window.app!.rootGraph.extra?.linearMode as boolean | undefined
|
||||
})
|
||||
}
|
||||
|
||||
async getOpenWorkflowCount(): Promise<number> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow.workflows
|
||||
.length
|
||||
})
|
||||
}
|
||||
|
||||
async isCurrentWorkflowModified(): Promise<boolean | undefined> {
|
||||
return this.comfyPage.page.evaluate(() => {
|
||||
return (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
|
||||
@@ -77,6 +77,10 @@ export const TestIds = {
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
},
|
||||
builder: {
|
||||
footerNav: 'builder-footer-nav',
|
||||
saveButton: 'builder-save-button',
|
||||
saveAsButton: 'builder-save-as-button',
|
||||
saveAsChevron: 'builder-save-as-chevron',
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -2,10 +2,60 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { setupSubgraphBuilder } from '../helpers/builderTestUtils'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
|
||||
import { setupBuilder } from '../helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
/**
|
||||
* Open the save-as dialog, fill name + view type, click save,
|
||||
* and wait for the success dialog.
|
||||
*/
|
||||
async function builderSaveAs(
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string,
|
||||
viewType: 'App' | 'Node graph'
|
||||
) {
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a different workflow, then reopen the named one from the sidebar.
|
||||
* Caller must ensure the page is in graph mode (not builder or app mode)
|
||||
* before calling.
|
||||
*/
|
||||
async function openWorkflowFromSidebar(comfyPage: ComfyPage, name: string) {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(name).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toContain(name)
|
||||
}).toPass({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* After a first save, open save-as again from the chevron,
|
||||
* fill name + view type, and save.
|
||||
*/
|
||||
async function reSaveAs(
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string,
|
||||
viewType: 'App' | 'Node graph'
|
||||
) {
|
||||
await appMode.footer.openSaveAsFromChevron()
|
||||
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
}
|
||||
|
||||
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
@@ -21,242 +71,301 @@ 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()
|
||||
const { saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
// The save-as dialog should appear with filename input and view type selection
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
await expect(dialog.getByRole('textbox')).toBeVisible()
|
||||
await expect(dialog.getByText('Save as')).toBeVisible()
|
||||
|
||||
// View type radio group should be present
|
||||
const radioGroup = dialog.getByRole('radiogroup')
|
||||
await expect(radioGroup).toBeVisible()
|
||||
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
|
||||
await expect(saveAs.nameInput).toBeVisible()
|
||||
await expect(saveAs.title).toBeVisible()
|
||||
await expect(saveAs.radioGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save as dialog allows entering filename and saving', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const workflowName = `${Date.now()} builder-save-test`
|
||||
const input = dialog.getByRole('textbox')
|
||||
await input.fill(workflowName)
|
||||
|
||||
// Save button should be enabled now
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await expect(saveButton).toBeEnabled()
|
||||
await saveButton.click()
|
||||
|
||||
// Success dialog should appear
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} builder-save`, 'App')
|
||||
})
|
||||
|
||||
test('Save as dialog disables save when filename is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
const { saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
|
||||
await saveAs.nameInput.fill('')
|
||||
await expect(saveAs.saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
// Clear the filename input
|
||||
const input = dialog.getByRole('textbox')
|
||||
await input.fill('')
|
||||
test('View type can be toggled in save-as dialog', async ({ comfyPage }) => {
|
||||
const { saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
// Save button should be disabled
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await expect(saveButton).toBeDisabled()
|
||||
await expect(saveAs.dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const appRadio = saveAs.viewTypeRadio('App')
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
const graphRadio = saveAs.viewTypeRadio('Node graph')
|
||||
await graphRadio.click()
|
||||
await expect(graphRadio).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
test('Builder step navigation works correctly', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
const { footer } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
// Should start at outputs (we ended there in setup)
|
||||
// Navigate to inputs
|
||||
await appMode.goToInputs()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
|
||||
// Back button should be disabled on first step
|
||||
const backButton = appMode.getFooterButton('Back')
|
||||
await expect(backButton).toBeDisabled()
|
||||
await expect(footer.backButton).toBeDisabled()
|
||||
await expect(footer.nextButton).toBeEnabled()
|
||||
|
||||
// Next button should be enabled
|
||||
const nextButton = appMode.getFooterButton('Next')
|
||||
await expect(nextButton).toBeEnabled()
|
||||
await footer.next()
|
||||
await expect(footer.backButton).toBeEnabled()
|
||||
|
||||
// Navigate forward
|
||||
await appMode.next()
|
||||
|
||||
// Back button should now be enabled
|
||||
await expect(backButton).toBeEnabled()
|
||||
|
||||
// Navigate to preview (last step)
|
||||
await appMode.next()
|
||||
|
||||
// Next button should be disabled on last step
|
||||
await expect(nextButton).toBeDisabled()
|
||||
await footer.next()
|
||||
await expect(footer.nextButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Escape key exits builder mode', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
// Verify builder toolbar is visible
|
||||
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
|
||||
await expect(toolbar).toBeVisible()
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
|
||||
|
||||
// Press Escape
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Builder toolbar should be gone
|
||||
await expect(toolbar).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
|
||||
await expect(toolbar).toBeVisible()
|
||||
|
||||
await appMode.exitBuilder()
|
||||
|
||||
await expect(toolbar).not.toBeVisible()
|
||||
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
|
||||
await comfyPage.appMode.footer.exitBuilder()
|
||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Save button directly saves for previously saved workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
const { footer, saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
// First save via builder save-as to make it non-temporary
|
||||
await appMode.clickSave()
|
||||
const saveAsDialog = page.getByRole('dialog')
|
||||
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
|
||||
const workflowName = `${Date.now()} builder-direct-save`
|
||||
await saveAsDialog.getByRole('textbox').fill(workflowName)
|
||||
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// Dismiss the success dialog
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await successDialog.getByText('Close', { exact: true }).click()
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} direct-save`, 'App')
|
||||
await saveAs.closeButton.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.appMode.steps.goToInputs()
|
||||
await comfyPage.appMode.select.deleteInput('seed')
|
||||
await expect(footer.saveButton).toBeEnabled({ timeout: 5000 })
|
||||
|
||||
await footer.saveButton.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()
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 2000 })
|
||||
await expect(appMode.getFooterButton(/^Save$/)).toBeDisabled()
|
||||
await expect(saveAs.dialog).not.toBeVisible({ timeout: 2000 })
|
||||
await expect(footer.saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Split button chevron opens save-as for saved workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
const { footer, saveAs } = comfyPage.appMode
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
// First save via builder save-as to make it non-temporary
|
||||
await appMode.clickSave()
|
||||
const saveAsDialog = page.getByRole('dialog')
|
||||
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
|
||||
const workflowName = `${Date.now()} builder-split-btn`
|
||||
await saveAsDialog.getByRole('textbox').fill(workflowName)
|
||||
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// Dismiss the success dialog
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await successDialog.getByText('Close', { exact: true }).click()
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} split-btn`, 'App')
|
||||
await saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Click the chevron dropdown trigger
|
||||
const chevronButton = appMode.getFooterButton('Save as')
|
||||
await chevronButton.click()
|
||||
await footer.openSaveAsFromChevron()
|
||||
|
||||
// "Save as" menu item should appear
|
||||
const menuItem = page.getByRole('menuitem', { name: 'Save as' })
|
||||
await expect(menuItem).toBeVisible({ timeout: 5000 })
|
||||
await menuItem.click()
|
||||
|
||||
// Save-as dialog should appear
|
||||
const newSaveAsDialog = page.getByRole('dialog')
|
||||
await expect(newSaveAsDialog.getByText('Save as')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(newSaveAsDialog.getByRole('textbox')).toBeVisible()
|
||||
await expect(saveAs.title).toBeVisible({ timeout: 5000 })
|
||||
await expect(saveAs.nameInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Connect output popover appears when no outputs selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
|
||||
// Without selecting any outputs, click the save button
|
||||
// It should trigger the connect-output popover
|
||||
await appMode.clickSave()
|
||||
await comfyPage.appMode.footer.saveAsButton.click()
|
||||
|
||||
// The popover should show a message about connecting outputs
|
||||
await expect(
|
||||
page.getByText('Connect an output', { exact: false })
|
||||
comfyPage.page.getByText('Connect an output', { exact: false })
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
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()
|
||||
test('save as app produces correct extension and linearMode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-ext`, 'App')
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toContain('.app.json')
|
||||
|
||||
// App should be selected by default
|
||||
const appRadio = dialog.getByRole('radio', { name: /App/ })
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
|
||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
||||
expect(linearMode).toBe(true)
|
||||
})
|
||||
|
||||
// Click Node graph option
|
||||
const graphRadio = dialog.getByRole('radio', { name: /Node graph/ })
|
||||
await graphRadio.click()
|
||||
await expect(graphRadio).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'false')
|
||||
test('save as node graph produces correct extension and linearMode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(
|
||||
comfyPage.appMode,
|
||||
`${Date.now()} graph-ext`,
|
||||
'Node graph'
|
||||
)
|
||||
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toMatch(/\.json$/)
|
||||
expect(path).not.toContain('.app.json')
|
||||
|
||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
||||
expect(linearMode).toBe(false)
|
||||
})
|
||||
|
||||
test('save as app View App button enters app mode', async ({ comfyPage }) => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, `${Date.now()} app-view`, 'App')
|
||||
|
||||
await comfyPage.appMode.saveAs.viewAppButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.workflow.getActiveWorkflowActiveAppMode()).toBe(
|
||||
'app'
|
||||
)
|
||||
})
|
||||
|
||||
test('save as node graph Exit builder exits builder mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(
|
||||
comfyPage.appMode,
|
||||
`${Date.now()} graph-exit`,
|
||||
'Node graph'
|
||||
)
|
||||
|
||||
await comfyPage.appMode.saveAs.exitBuilderButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.appMode.steps.toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('save as with different mode does not modify the original workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
const originalName = `${Date.now()} original`
|
||||
await builderSaveAs(appMode, originalName, 'App')
|
||||
const originalPath = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(originalPath).toContain('.app.json')
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Re-save as node graph — creates a copy
|
||||
await reSaveAs(appMode, `${Date.now()} copy`, 'Node graph')
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const newPath = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(newPath).not.toBe(originalPath)
|
||||
expect(newPath).not.toContain('.app.json')
|
||||
|
||||
// Dismiss success dialog, exit app mode, reopen the original
|
||||
await appMode.saveAs.dismissButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await appMode.toggleAppMode()
|
||||
await openWorkflowFromSidebar(comfyPage, originalName)
|
||||
|
||||
const linearMode = await comfyPage.workflow.getLinearModeFromGraph()
|
||||
expect(linearMode).toBe(true)
|
||||
})
|
||||
|
||||
test('save as with same name and same mode overwrites in place', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
const name = `${Date.now()} overwrite`
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await builderSaveAs(appMode, name, 'App')
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
|
||||
await reSaveAs(appMode, name, 'App')
|
||||
|
||||
await expect(appMode.saveAs.overwriteDialog).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.overwriteButton.click()
|
||||
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const pathAfterSecond = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(pathAfterSecond).toBe(pathAfterFirst)
|
||||
})
|
||||
|
||||
test('save as with same name but different mode creates a new file', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
const name = `${Date.now()} mode-change`
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
await builderSaveAs(appMode, name, 'App')
|
||||
const pathAfterFirst = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(pathAfterFirst).toContain('.app.json')
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await reSaveAs(appMode, name, 'Node graph')
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const pathAfterSecond = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(pathAfterSecond).not.toBe(pathAfterFirst)
|
||||
expect(pathAfterSecond).toMatch(/\.json$/)
|
||||
expect(pathAfterSecond).not.toContain('.app.json')
|
||||
})
|
||||
|
||||
test('save as app workflow reloads in app mode', async ({ comfyPage }) => {
|
||||
const name = `${Date.now()} reload-app`
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, name, 'App')
|
||||
await comfyPage.appMode.saveAs.dismissButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.appMode.footer.exitBuilder()
|
||||
|
||||
await openWorkflowFromSidebar(comfyPage, name)
|
||||
|
||||
const mode = await comfyPage.workflow.getActiveWorkflowInitialMode()
|
||||
expect(mode).toBe('app')
|
||||
})
|
||||
|
||||
test('save as node graph workflow reloads in node graph mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const name = `${Date.now()} reload-graph`
|
||||
await setupBuilder(comfyPage)
|
||||
await builderSaveAs(comfyPage.appMode, name, 'Node graph')
|
||||
await comfyPage.appMode.saveAs.dismissButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await openWorkflowFromSidebar(comfyPage, name)
|
||||
|
||||
const mode = await comfyPage.workflow.getActiveWorkflowInitialMode()
|
||||
expect(mode).toBe('graph')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
<!-- Main toolbar -->
|
||||
<nav
|
||||
data-testid="builder-footer-nav"
|
||||
class="flex items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
>
|
||||
<Button variant="textonly" size="lg" @click="onExitBuilder">
|
||||
@@ -37,7 +38,11 @@
|
||||
:is-select-active="isSelectStep"
|
||||
@switch="navigateToStep('builder:outputs')"
|
||||
>
|
||||
<Button size="lg" :class="cn('w-24', disabledSaveClasses)">
|
||||
<Button
|
||||
size="lg"
|
||||
:class="cn('w-24', disabledSaveClasses)"
|
||||
data-testid="builder-save-as-button"
|
||||
>
|
||||
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</ConnectOutputPopover>
|
||||
@@ -50,6 +55,7 @@
|
||||
:disabled="!isModified"
|
||||
class="flex-1"
|
||||
:class="isModified ? activeSaveClasses : disabledSaveClasses"
|
||||
data-testid="builder-save-button"
|
||||
@click="save()"
|
||||
>
|
||||
{{ t('g.save') }}
|
||||
@@ -60,6 +66,7 @@
|
||||
size="lg"
|
||||
:aria-label="t('builderToolbar.saveAs')"
|
||||
data-save-chevron
|
||||
data-testid="builder-save-as-chevron"
|
||||
class="w-6 rounded-l-none border-l border-border-default px-0"
|
||||
>
|
||||
<i
|
||||
@@ -87,7 +94,13 @@
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</ButtonGroup>
|
||||
<Button v-else size="lg" :class="activeSaveClasses" @click="saveAs()">
|
||||
<Button
|
||||
v-else
|
||||
size="lg"
|
||||
:class="activeSaveClasses"
|
||||
data-testid="builder-save-as-button"
|
||||
@click="saveAs()"
|
||||
>
|
||||
{{ t('builderToolbar.saveAs') }}
|
||||
</Button>
|
||||
</nav>
|
||||
|
||||
Reference in New Issue
Block a user