mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
- add app mode pruning tests
- always prune when entering app builder
This commit is contained in:
39
browser_tests/assets/inputs/dynamic_combo.json
Normal file
39
browser_tests/assets/inputs/dynamic_combo.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ResizeImageMaskNode",
|
||||
"pos": [100, 100],
|
||||
"size": [315, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{ "name": "result", "type": "IMAGE", "links": null }],
|
||||
"properties": {
|
||||
"Node name for S&R": "ResizeImageMaskNode"
|
||||
},
|
||||
"widgets_values": ["scale dimensions", 512, 512, "center", "area"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "SaveImage",
|
||||
"pos": [500, 100],
|
||||
"size": [210, 58],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "images", "type": "IMAGE", "link": null }],
|
||||
"properties": {}
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -109,6 +109,14 @@ export class VueNodeHelpers {
|
||||
await this.page.keyboard.press('Delete')
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a node by ID and delete it.
|
||||
*/
|
||||
async deleteNode(nodeId: string): Promise<void> {
|
||||
await this.selectNode(nodeId)
|
||||
await this.deleteSelected()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected Vue nodes using Backspace key
|
||||
*/
|
||||
@@ -158,6 +166,21 @@ export class VueNodeHelpers {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an option from a combo widget on a node.
|
||||
*/
|
||||
async selectComboOption(
|
||||
nodeTitle: string,
|
||||
widgetName: string,
|
||||
optionName: string
|
||||
): Promise<void> {
|
||||
const node = this.getNodeByTitle(nodeTitle)
|
||||
await node.getByRole('combobox', { name: widgetName, exact: true }).click()
|
||||
await this.page
|
||||
.getByRole('option', { name: optionName, exact: true })
|
||||
.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get controls for input number widgets (increment/decrement buttons and input)
|
||||
*/
|
||||
|
||||
@@ -39,13 +39,15 @@ export class AppModeHelper {
|
||||
await this.comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
}
|
||||
|
||||
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
|
||||
/** Enter builder mode via the "Workflow actions" dropdown. */
|
||||
async enterBuilder() {
|
||||
await this.page
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
.first()
|
||||
.click()
|
||||
await this.page.getByRole('menuitem', { name: 'Build app' }).click()
|
||||
await this.page
|
||||
.getByRole('menuitem', { name: /Build app|Edit app/ })
|
||||
.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
@@ -92,6 +94,16 @@ export class AppModeHelper {
|
||||
await this.toggleAppMode()
|
||||
}
|
||||
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
get connectOutputPopover(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.connectOutputPopover)
|
||||
}
|
||||
|
||||
/** The empty-state placeholder shown when no outputs are selected. */
|
||||
get outputPlaceholder(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.outputPlaceholder)
|
||||
}
|
||||
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
get linearWidgets(): Locator {
|
||||
return this.page.locator('[data-testid="linear-widgets"]')
|
||||
|
||||
@@ -145,6 +145,26 @@ export class BuilderSelectHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the subtitle locator for a builder IoItem by its title text.
|
||||
* Useful for asserting "Widget not visible" on disconnected inputs.
|
||||
*/
|
||||
getInputItemSubtitle(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.ioItemSubtitle)
|
||||
}
|
||||
|
||||
/** All IoItem locators in the current step sidebar. */
|
||||
get inputItems(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.ioItem)
|
||||
}
|
||||
|
||||
/** All IoItem title locators in the inputs step sidebar. */
|
||||
get inputItemTitles(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.ioItemTitle)
|
||||
|
||||
@@ -86,10 +86,13 @@ export const TestIds = {
|
||||
saveAsChevron: 'builder-save-as-chevron',
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
ioItemSubtitle: 'builder-io-item-subtitle',
|
||||
widgetActionsMenu: 'widget-actions-menu',
|
||||
opensAs: 'builder-opens-as',
|
||||
widgetItem: 'builder-widget-item',
|
||||
widgetLabel: 'builder-widget-label'
|
||||
widgetLabel: 'builder-widget-label',
|
||||
outputPlaceholder: 'builder-output-placeholder',
|
||||
connectOutputPopover: 'builder-connect-output-popover'
|
||||
},
|
||||
appMode: {
|
||||
widgetItem: 'app-mode-widget-item'
|
||||
|
||||
105
browser_tests/tests/appModePruning.spec.ts
Normal file
105
browser_tests/tests/appModePruning.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { setupBuilder } from '../helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
const RESIZE_NODE_TITLE = 'Resize Image/Mask'
|
||||
const RESIZE_NODE_ID = '1'
|
||||
const SAVE_IMAGE_NODE_ID = '9'
|
||||
|
||||
/**
|
||||
* Load the dynamic combo workflow, enter builder,
|
||||
* select a dynamic sub-widget as input and SaveImage as output.
|
||||
*/
|
||||
async function setupDynamicComboBuilder(comfyPage: ComfyPage) {
|
||||
const { appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('inputs/dynamic_combo')
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await appMode.select.selectInputWidget(RESIZE_NODE_TITLE, 'resize_type.width')
|
||||
await appMode.steps.goToOutputs()
|
||||
await appMode.select.selectOutputNode('Save Image')
|
||||
}
|
||||
|
||||
test.describe('App Mode Pruning', { tag: ['@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('prunes deleted outputs', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
// Enter builder with default workflow (seed input + SaveImage output)
|
||||
await setupBuilder(comfyPage)
|
||||
|
||||
// Verify save-as dialog opens
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await expect(appMode.saveAs.dialog).toBeVisible()
|
||||
await appMode.saveAs.dialog.press('Escape')
|
||||
|
||||
// Exit builder, delete SaveImage node
|
||||
await appMode.footer.exitBuilder()
|
||||
await comfyPage.vueNodes.deleteNode(SAVE_IMAGE_NODE_ID)
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator(SAVE_IMAGE_NODE_ID)
|
||||
).not.toBeAttached()
|
||||
|
||||
// Re-enter builder - pruning should auto-clean stale outputs
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToOutputs()
|
||||
await expect(appMode.outputPlaceholder).toBeVisible()
|
||||
|
||||
// Verify can't save
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await expect(appMode.connectOutputPopover).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not prune missing widgets when node still exists for dynamic widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await setupDynamicComboBuilder(comfyPage)
|
||||
await appMode.footer.exitBuilder()
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
// Change dynamic combo from "scale dimensions" to "scale by multiplier"
|
||||
// This removes the width/height widgets and adds factor
|
||||
await comfyPage.vueNodes.selectComboOption(
|
||||
RESIZE_NODE_TITLE,
|
||||
'resize_type',
|
||||
'scale by multiplier'
|
||||
)
|
||||
|
||||
// Re-enter builder - node exists but widget is gone
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
|
||||
// The input should still be listed but show "Widget not visible"
|
||||
const subtitle = appMode.select.getInputItemSubtitle('resize_type.width')
|
||||
await expect(subtitle).toHaveText('Widget not visible')
|
||||
})
|
||||
|
||||
test('prunes missing widgets when node deleted', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
|
||||
await setupDynamicComboBuilder(comfyPage)
|
||||
await appMode.footer.exitBuilder()
|
||||
|
||||
// Delete the ResizeImageMaskNode entirely
|
||||
await comfyPage.vueNodes.deleteNode(RESIZE_NODE_ID)
|
||||
|
||||
// Re-enter builder - pruning should auto-clean stale inputs
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItems).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -314,6 +314,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
</PropertiesAccordionItem>
|
||||
<div
|
||||
v-if="isSelectOutputsMode && !appModeStore.selectedOutputs.length"
|
||||
data-testid="builder-output-placeholder"
|
||||
class="m-4 flex flex-1 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-warning-background bg-warning-background/20 text-center text-sm text-warning-background"
|
||||
>
|
||||
{{ t('linearMode.builder.outputPlaceholder') }}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
align="end"
|
||||
:side-offset="18"
|
||||
:collision-padding="10"
|
||||
data-testid="builder-connect-output-popover"
|
||||
class="data-[state=open]:data-[side=bottom]:animate-slideUpAndFade z-1001 w-80 rounded-xl border border-border-default bg-base-background shadow-interface will-change-[transform,opacity]"
|
||||
>
|
||||
<div class="flex h-12 items-center justify-between px-4">
|
||||
|
||||
@@ -83,6 +83,21 @@ function createBuilderWorkflow(
|
||||
return workflow as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workflow with a persisted output so enterBuilder
|
||||
* routes to builder:arrange (requires node 1 to resolve).
|
||||
*/
|
||||
function createBuilderWorkflowWithOutputs(
|
||||
activeMode: string
|
||||
): LoadedComfyWorkflow {
|
||||
mockResolveNode.mockReturnValue(fromAny({ id: 1 }))
|
||||
const workflow = createBuilderWorkflow(activeMode)
|
||||
;(workflow.changeTracker!.activeState as Record<string, unknown>).extra = {
|
||||
linearData: { inputs: [], outputs: [1] }
|
||||
}
|
||||
return workflow
|
||||
}
|
||||
|
||||
describe('appModeStore', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
let store: ReturnType<typeof useAppModeStore>
|
||||
@@ -100,8 +115,7 @@ describe('appModeStore', () => {
|
||||
|
||||
describe('enterBuilder', () => {
|
||||
it('navigates to builder:arrange when in app mode with outputs', () => {
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('app')
|
||||
store.selectedOutputs.push(1)
|
||||
workflowStore.activeWorkflow = createBuilderWorkflowWithOutputs('app')
|
||||
|
||||
store.enterBuilder()
|
||||
|
||||
@@ -425,8 +439,7 @@ describe('appModeStore', () => {
|
||||
|
||||
it('does not enable Vue nodes when entering builder:arrange', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('app')
|
||||
store.selectedOutputs.push(1)
|
||||
workflowStore.activeWorkflow = createBuilderWorkflowWithOutputs('app')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
|
||||
@@ -132,6 +132,9 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Prune stale references
|
||||
resetSelectedToWorkflow()
|
||||
|
||||
useSidebarTabStore().activeSidebarTabId = null
|
||||
|
||||
setMode(
|
||||
|
||||
Reference in New Issue
Block a user