Compare commits

...

9 Commits

Author SHA1 Message Date
pythongosssss
d6509722c6 Ensure all save/save as buttons are the same width 2026-03-28 12:05:36 -07:00
pythongosssss
9e4e1d8f4b rabbit + fix 2026-03-28 11:42:26 -07:00
pythongosssss
566ad19866 improve 2026-03-28 10:44:50 -07:00
pythongosssss
66675dc27e add save as tests
further simplify/share between tests
2026-03-28 10:44:50 -07:00
pythongosssss
a4953083b6 refactor save tests to remove getByX and use fixtures 2026-03-28 10:44:50 -07:00
pythongosssss
58c6b68fcc refactor to component specific fixtures 2026-03-28 10:44:50 -07:00
pythongosssss
fe525fb879 guard graph extra 2026-03-28 10:39:24 -07:00
pythongosssss
84a5c8a404 fix test 2026-03-28 08:55:27 -07:00
pythongosssss
4ef2ce96bc fix save as not using correct extension or persisting mode on change 2026-03-28 07:10:22 -07:00
17 changed files with 1030 additions and 448 deletions

View File

@@ -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()
}
}

View File

@@ -0,0 +1,73 @@
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 saveGroup(): Locator {
return this.page.getByTestId(TestIds.builder.saveGroup)
}
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()
}
}

View 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()
}
}

View File

@@ -0,0 +1,135 @@
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)
}
/** 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)
.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(() => {
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()
}
}

View 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()
}
}

View File

@@ -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

View File

@@ -77,6 +77,11 @@ export const TestIds = {
subgraphEnterButton: 'subgraph-enter-button'
},
builder: {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',
saveAsButton: 'builder-save-as-button',
saveGroup: 'builder-save-group',
saveAsChevron: 'builder-save-as-chevron',
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',
widgetActionsMenu: 'widget-actions-menu',

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

@@ -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,231 +71,336 @@ 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()
// Now click save again — should save directly
await appMode.clickSave()
// Modify the workflow so the save button becomes enabled
await comfyPage.appMode.steps.goToInputs()
await comfyPage.appMode.select.deleteInput('seed')
await expect(footer.saveButton).toBeEnabled({ timeout: 5000 })
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 2000 })
await expect(appMode.getFooterButton(/^Save$/)).toBeDisabled()
await footer.saveButton.click()
await comfyPage.nextFrame()
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()
await expect(saveAs.title).toBeVisible({ timeout: 5000 })
await expect(saveAs.nameInput).toBeVisible()
})
// 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()
test('Save button width is consistent across all states', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
// State 1: Disabled "Save as" (no outputs selected)
const disabledBox = await appMode.footer.saveAsButton.boundingBox()
expect(disabledBox).toBeTruthy()
// Select I/O to enable the button
await appMode.steps.goToInputs()
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await appMode.select.selectInputWidget(ksampler)
await appMode.steps.goToOutputs()
await appMode.select.selectOutputNode()
// State 2: Enabled "Save as" (unsaved, has outputs)
const enabledBox = await appMode.footer.saveAsButton.boundingBox()
expect(enabledBox).toBeTruthy()
expect(enabledBox!.width).toBe(disabledBox!.width)
// Save the workflow to transition to the Save + chevron state
await builderSaveAs(appMode, `${Date.now()} width-test`, 'App')
await appMode.saveAs.closeButton.click()
await comfyPage.nextFrame()
// State 3: Save + chevron button group (saved workflow)
const saveButtonGroupBox = await appMode.footer.saveGroup.boundingBox()
expect(saveButtonGroupBox).toBeTruthy()
expect(saveButtonGroupBox!.width).toBe(disabledBox!.width)
})
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')
})
})

View File

@@ -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,19 +38,27 @@
: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>
<ButtonGroup
v-else-if="isSaved"
data-testid="builder-save-group"
class="w-24 rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
>
<Button
size="lg"
:disabled="!isModified"
class="flex-1"
:class="isModified ? activeSaveClasses : disabledSaveClasses"
:class="
cn('w-24', isModified ? activeSaveClasses : disabledSaveClasses)
"
data-testid="builder-save-button"
@click="save()"
>
{{ t('g.save') }}
@@ -60,6 +69,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 +97,13 @@
</DropdownMenuPortal>
</DropdownMenuRoot>
</ButtonGroup>
<Button v-else size="lg" :class="activeSaveClasses" @click="saveAs()">
<Button
v-else
size="lg"
:class="cn('w-24', activeSaveClasses)"
data-testid="builder-save-as-button"
@click="saveAs()"
>
{{ t('builderToolbar.saveAs') }}
</Button>
</nav>

View File

@@ -6,6 +6,7 @@ import { useBuilderSave } from './useBuilderSave'
const mockSetMode = vi.hoisted(() => vi.fn())
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockTrackEnterLinear = vi.hoisted(() => vi.fn())
const mockTrackDefaultViewSet = vi.hoisted(() => vi.fn())
const mockSaveWorkflow = vi.hoisted(() => vi.fn<() => Promise<void>>())
const mockSaveWorkflowAs = vi.hoisted(() =>
vi.fn<() => Promise<boolean | null>>()
@@ -13,7 +14,6 @@ const mockSaveWorkflowAs = vi.hoisted(() =>
const mockShowLayoutDialog = vi.hoisted(() => vi.fn())
const mockShowConfirmDialog = vi.hoisted(() => vi.fn())
const mockCloseDialog = vi.hoisted(() => vi.fn())
const mockSetWorkflowDefaultView = vi.hoisted(() => vi.fn())
const mockExitBuilder = vi.hoisted(() => vi.fn())
const mockActiveWorkflow = ref<{
@@ -30,7 +30,10 @@ vi.mock('@/composables/useErrorHandling', () => ({
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackEnterLinear: mockTrackEnterLinear })
useTelemetry: () => ({
trackEnterLinear: mockTrackEnterLinear,
trackDefaultViewSet: mockTrackDefaultViewSet
})
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
@@ -60,10 +63,6 @@ vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ closeDialog: mockCloseDialog })
}))
vi.mock('./builderViewOptions', () => ({
setWorkflowDefaultView: mockSetWorkflowDefaultView
}))
vi.mock('@/components/dialog/confirm/confirmDialog', () => ({
showConfirmDialog: mockShowConfirmDialog
}))
@@ -190,7 +189,7 @@ describe('useBuilderSave', () => {
}
}
it('onSave calls saveWorkflowAs then setWorkflowDefaultView on success', async () => {
it('onSave calls saveWorkflowAs with isApp and tracks telemetry', async () => {
mockSaveWorkflowAs.mockResolvedValueOnce(true)
const { onSave } = getSaveDialogProps()
@@ -199,35 +198,40 @@ describe('useBuilderSave', () => {
expect(mockSaveWorkflowAs).toHaveBeenCalledWith(
mockActiveWorkflow.value,
{
filename: 'new-name'
filename: 'new-name',
isApp: true
}
)
expect(mockSetWorkflowDefaultView).toHaveBeenCalledWith(
mockActiveWorkflow.value,
true
)
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
default_view: 'app'
})
})
it('onSave uses fresh activeWorkflow reference for setWorkflowDefaultView', async () => {
const newWorkflow = { filename: 'new-name', initialMode: 'app' }
mockSaveWorkflowAs.mockImplementationOnce(async () => {
mockActiveWorkflow.value = newWorkflow
return true
})
it('onSave passes isApp: false when saving as graph', async () => {
mockSaveWorkflowAs.mockResolvedValueOnce(true)
const { onSave } = getSaveDialogProps()
await onSave('new-name', true)
await onSave('new-name', false)
expect(mockSetWorkflowDefaultView).toHaveBeenCalledWith(newWorkflow, true)
expect(mockSaveWorkflowAs).toHaveBeenCalledWith(
mockActiveWorkflow.value,
{
filename: 'new-name',
isApp: false
}
)
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
default_view: 'graph'
})
})
it('onSave does not mutate or close when saveWorkflowAs returns falsy', async () => {
it('onSave does not track or close when saveWorkflowAs returns falsy', async () => {
mockSaveWorkflowAs.mockResolvedValueOnce(null)
const { onSave } = getSaveDialogProps()
await onSave('new-name', false)
expect(mockSetWorkflowDefaultView).not.toHaveBeenCalled()
expect(mockTrackDefaultViewSet).not.toHaveBeenCalled()
expect(mockCloseDialog).not.toHaveBeenCalled()
})

View File

@@ -10,7 +10,6 @@ import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import { ref } from 'vue'
import { setWorkflowDefaultView } from './builderViewOptions'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
const SAVE_DIALOG_KEY = 'builder-save'
@@ -71,13 +70,14 @@ export function useBuilderSave() {
if (!workflow) return
const saved = await workflowService.saveWorkflowAs(workflow, {
filename
filename,
isApp: openAsApp
})
if (!saved) return
const activeWorkflow = workflowStore.activeWorkflow
if (!activeWorkflow) return
setWorkflowDefaultView(activeWorkflow, openAsApp)
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})
closeDialog(SAVE_DIALOG_KEY)
showSuccessDialog(openAsApp ? 'app' : 'graph')
} catch (e) {

View File

@@ -37,8 +37,6 @@ export function useAppMode() {
)
function setMode(newMode: AppMode) {
if (newMode === mode.value) return
const workflow = workflowStore.activeWorkflow
if (workflow) workflow.activeMode = newMode
}

View File

@@ -71,7 +71,7 @@ vi.mock('@/services/dialogService', () => ({
vi.mock('@/scripts/app', () => ({
app: {
canvas: { ds: { offset: [0, 0], scale: 1 } },
rootGraph: { serialize: vi.fn(() => ({})) },
rootGraph: { serialize: vi.fn(() => ({})), extra: {} },
loadGraphData: vi.fn()
}
}))
@@ -93,7 +93,11 @@ vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
useTelemetry: () => ({
trackDefaultViewSet: vi.fn(),
trackWorkflowSaved: vi.fn(),
trackEnterLinear: vi.fn()
})
}))
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStore', () => ({
@@ -328,48 +332,6 @@ describe('useWorkflowService', () => {
})
})
describe('saveWorkflowAs', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
setActivePinia(createTestingPinia())
workflowStore = useWorkflowStore()
})
it('should rename then save when workflow is temporary', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/Unsaved Workflow.json'
})
Object.defineProperty(workflow, 'isTemporary', { get: () => true })
vi.mocked(workflowStore.getWorkflowByPath).mockReturnValue(null)
vi.mocked(workflowStore.renameWorkflow).mockResolvedValue()
vi.mocked(workflowStore.saveWorkflow).mockResolvedValue()
const result = await useWorkflowService().saveWorkflowAs(workflow, {
filename: 'my-workflow'
})
expect(result).toBe(true)
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
workflow,
'workflows/my-workflow.json'
)
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
})
it('should return false when no filename is provided', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/test.json'
})
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
const result = await useWorkflowService().saveWorkflowAs(workflow)
expect(result).toBe(false)
expect(workflowStore.saveWorkflow).not.toHaveBeenCalled()
})
})
describe('afterLoadNewGraph', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let existingWorkflow: LoadedComfyWorkflow
@@ -538,6 +500,20 @@ describe('useWorkflowService', () => {
expect(workflow.initialMode).toBe('graph')
expect(appMode.mode.value).toBe('builder:arrange')
})
it('sets activeMode even when initialMode already matches', () => {
const workflow = createModeTestWorkflow({
initialMode: 'app',
activeMode: null
})
workflowStore.activeWorkflow = workflow
// mode.value is 'app' via initialMode fallback, but activeMode
// must still be set so the UI transitions to app view
appMode.setMode('app')
expect(workflow.activeMode).toBe('app')
})
})
describe('afterLoadNewGraph initializes initialMode', () => {
@@ -686,6 +662,7 @@ describe('useWorkflowService', () => {
service = useWorkflowService()
vi.spyOn(workflowStore, 'saveWorkflow').mockResolvedValue()
vi.spyOn(workflowStore, 'renameWorkflow').mockResolvedValue()
app.rootGraph.extra = {}
})
function createTemporaryWorkflow(
@@ -703,6 +680,34 @@ describe('useWorkflowService', () => {
return workflow as LoadedComfyWorkflow
}
it('should rename then save when workflow is temporary', async () => {
const workflow = createTemporaryWorkflow()
vi.mocked(workflowStore.getWorkflowByPath).mockReturnValue(null)
const result = await service.saveWorkflowAs(workflow, {
filename: 'my-workflow'
})
expect(result).toBe(true)
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
workflow,
'workflows/my-workflow.json'
)
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
})
it('should return false when no filename is provided', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/test.json'
})
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
const result = await service.saveWorkflowAs(workflow)
expect(result).toBe(false)
expect(workflowStore.saveWorkflow).not.toHaveBeenCalled()
})
it('appends .app.json extension when initialMode is app', async () => {
const workflow = createTemporaryWorkflow()
workflow.initialMode = 'app'
@@ -737,6 +742,211 @@ describe('useWorkflowService', () => {
'workflows/my-workflow.json'
)
})
it('uses isApp option over initialMode when provided (graph -> app)', async () => {
const workflow = createTemporaryWorkflow()
workflow.initialMode = 'graph'
await service.saveWorkflowAs(workflow, {
filename: 'my-workflow',
isApp: true
})
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
workflow,
'workflows/my-workflow.app.json'
)
})
it('uses isApp option over initialMode when provided (app -> graph)', async () => {
const workflow = createTemporaryWorkflow()
workflow.initialMode = 'app'
await service.saveWorkflowAs(workflow, {
filename: 'my-workflow',
isApp: false
})
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
workflow,
'workflows/my-workflow.json'
)
})
it('creates a copy when saving same name with different mode (not self-overwrite)', async () => {
const source = createModeTestWorkflow({
path: 'workflows/test.json',
initialMode: 'graph'
})
const copy = createModeTestWorkflow({
path: 'workflows/test.app.json'
})
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
await service.saveWorkflowAs(source, {
filename: 'test',
isApp: true
})
// Different extension means different path, so it's not a self-overwrite
// — a new copy is created instead of modifying the source in place
expect(source.initialMode).toBe('graph')
expect(workflowStore.saveAs).toHaveBeenCalledWith(
source,
'workflows/test.app.json'
)
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(copy)
})
it('self-overwrites when saving same name with same mode', async () => {
const source = createModeTestWorkflow({
path: 'workflows/test.app.json',
initialMode: 'app'
})
vi.spyOn(workflowStore, 'getWorkflowByPath').mockReturnValue(source)
mockConfirm.mockResolvedValue(true)
await service.saveWorkflowAs(source, {
filename: 'test',
isApp: true
})
// Same path → self-overwrite: saves in place via saveWorkflow, no copy
expect(workflowStore.saveAs).not.toHaveBeenCalled()
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(source)
})
it('does not modify source workflow mode when saving persisted workflow as different mode', async () => {
const source = createModeTestWorkflow({
path: 'workflows/original.json',
initialMode: 'graph'
})
const copy = createModeTestWorkflow({
path: 'workflows/copy.app.json'
})
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
await service.saveWorkflowAs(source, {
filename: 'copy',
isApp: true
})
expect(source.initialMode).toBe('graph')
expect(copy.initialMode).toBe('app')
expect(workflowStore.saveAs).toHaveBeenCalledWith(
source,
'workflows/copy.app.json'
)
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(copy)
})
it('does not modify source workflow mode when saving app as graph', async () => {
const source = createModeTestWorkflow({
path: 'workflows/original.app.json',
initialMode: 'app'
})
const copy = createModeTestWorkflow({
path: 'workflows/copy.json'
})
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
await service.saveWorkflowAs(source, {
filename: 'copy',
isApp: false
})
expect(source.initialMode).toBe('app')
expect(copy.initialMode).toBe('graph')
expect(workflowStore.saveAs).toHaveBeenCalledWith(
source,
'workflows/copy.json'
)
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(copy)
})
function captureLinearModeAtSaveTime() {
let value: boolean | undefined
vi.mocked(workflowStore.saveWorkflow).mockImplementation(async () => {
value = app.rootGraph.extra?.linearMode as boolean | undefined
})
return () => value
}
it('sets linearMode in graph data before saving (graph -> app)', async () => {
const workflow = createTemporaryWorkflow()
workflow.initialMode = 'graph'
app.rootGraph.extra = { linearMode: false }
const getLinearMode = captureLinearModeAtSaveTime()
await service.saveWorkflowAs(workflow, {
filename: 'my-workflow',
isApp: true
})
expect(getLinearMode()).toBe(true)
})
it('sets linearMode in graph data before saving (app -> graph)', async () => {
const workflow = createTemporaryWorkflow()
workflow.initialMode = 'app'
app.rootGraph.extra = { linearMode: true }
const getLinearMode = captureLinearModeAtSaveTime()
await service.saveWorkflowAs(workflow, {
filename: 'my-workflow',
isApp: false
})
expect(getLinearMode()).toBe(false)
})
it('sets linearMode before saving persisted workflow copy', async () => {
const source = createModeTestWorkflow({
path: 'workflows/original.json',
initialMode: 'graph'
})
app.rootGraph.extra = { linearMode: false }
const copy = createModeTestWorkflow({
path: 'workflows/original.app.json'
})
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
const getLinearMode = captureLinearModeAtSaveTime()
await service.saveWorkflowAs(source, {
filename: 'original',
isApp: true
})
expect(getLinearMode()).toBe(true)
})
it('does not change initialMode when isApp is omitted (persisted copy)', async () => {
const source = createModeTestWorkflow({
path: 'workflows/original.app.json',
initialMode: 'app'
})
// Real saveAs copies initialMode from source; replicate that here
const copy = createModeTestWorkflow({
path: 'workflows/copy.app.json',
initialMode: 'app'
})
vi.spyOn(workflowStore, 'saveAs').mockReturnValue(copy)
vi.spyOn(workflowStore, 'openWorkflow').mockResolvedValue(copy)
await service.saveWorkflowAs(source, { filename: 'copy' })
// saveWorkflowAs should not change initialMode when isApp is omitted
expect(copy.initialMode).toBe('app')
})
})
describe('saveWorkflow', () => {

View File

@@ -116,12 +116,12 @@ export const useWorkflowService = () => {
*/
const saveWorkflowAs = async (
workflow: ComfyWorkflow,
options: { filename?: string } = {}
options: { filename?: string; isApp?: boolean } = {}
): Promise<boolean> => {
const newFilename = options.filename ?? (await workflow.promptSave())
if (!newFilename) return false
const isApp = workflow.initialMode === 'app'
const isApp = options.isApp ?? workflow.initialMode === 'app'
const newPath =
workflow.directory + '/' + appendWorkflowJsonExt(newFilename, isApp)
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
@@ -138,17 +138,27 @@ export const useWorkflowService = () => {
}
}
workflow.changeTracker?.checkState()
if (isSelfOverwrite) {
workflow.changeTracker?.checkState()
await saveWorkflow(workflow)
} else if (workflow.isTemporary) {
await renameWorkflow(workflow, newPath)
await workflowStore.saveWorkflow(workflow)
} else {
const tempWorkflow = workflowStore.saveAs(workflow, newPath)
await openWorkflow(tempWorkflow)
await workflowStore.saveWorkflow(tempWorkflow)
let target: ComfyWorkflow
if (workflow.isTemporary) {
await renameWorkflow(workflow, newPath)
target = workflow
} else {
target = workflowStore.saveAs(workflow, newPath)
await openWorkflow(target)
}
if (options.isApp !== undefined) {
app.rootGraph.extra ??= {}
app.rootGraph.extra.linearMode = isApp
target.initialMode = isApp ? 'app' : 'graph'
}
target.changeTracker?.checkState()
await workflowStore.saveWorkflow(target)
}
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: true })

View File

@@ -282,6 +282,7 @@ const zExtra = z
workflowRendererVersion: zRendererType.optional(),
BlueprintDescription: z.string().optional(),
BlueprintSearchAliases: z.array(z.string()).optional(),
linearMode: z.boolean().optional(),
linearData: z
.object({
inputs: z.array(z.tuple([zNodeId, z.string()])).optional(),